From d0b9855bad8ae12abe235474edb8065d7a7cc635 Mon Sep 17 00:00:00 2001 From: rzuasti Date: Wed, 27 May 2026 14:58:11 -0400 Subject: [PATCH] Add devices screen to the Flutter frontend Lists devices with New/Registered/All filtering, pull-to-refresh, device-type icons, and visual differentiation between new and registered devices. Introduces a reusable StatusBadge widget for colour-coded labels. Co-Authored-By: Claude Sonnet 4.6 --- frontend/lib/devices/device_list.dart | 187 +++++++++++++++++++++++++ frontend/lib/model/device.dart | 28 ++++ frontend/lib/navigation.dart | 3 +- frontend/lib/utils/oott_api.dart | 16 +++ frontend/lib/widgets/status_badge.dart | 47 +++++++ 5 files changed, 280 insertions(+), 1 deletion(-) create mode 100644 frontend/lib/devices/device_list.dart create mode 100644 frontend/lib/model/device.dart create mode 100644 frontend/lib/widgets/status_badge.dart diff --git a/frontend/lib/devices/device_list.dart b/frontend/lib/devices/device_list.dart new file mode 100644 index 0000000..0fe97ad --- /dev/null +++ b/frontend/lib/devices/device_list.dart @@ -0,0 +1,187 @@ +import 'package:flutter/material.dart'; + +import '../model/device.dart'; +import '../utils/friendly_date_formatter.dart'; +import '../utils/oott_api.dart'; +import '../widgets/status_badge.dart'; + +enum _DeviceFilter { newDevices, registered, all } + +class DeviceList extends StatefulWidget { + const DeviceList({super.key}); + + @override + State createState() => _DeviceListState(); +} + +class _DeviceListState extends State { + _DeviceFilter _filter = _DeviceFilter.newDevices; + List _devices = []; + bool _isLoading = true; + String? _error; + + @override + void initState() { + super.initState(); + _loadDevices(); + } + + @override + void dispose() { + super.dispose(); + } + + Future _loadDevices() async { + setState(() { + _isLoading = _devices.isEmpty; + _error = null; + }); + try { + bool? isRegistered; + if (_filter == _DeviceFilter.newDevices) isRegistered = false; + if (_filter == _DeviceFilter.registered) isRegistered = true; + + final devices = await BackendAPI.instance.listDevices( + isRegistered: isRegistered, + ); + if (!mounted) return; + setState(() { + _devices = devices; + _isLoading = false; + }); + } catch (e) { + if (!mounted) return; + setState(() { + _error = e.toString(); + _isLoading = false; + }); + } + } + + IconData _deviceIcon(String deviceType) { + switch (deviceType.toLowerCase()) { + case 'phone': + return Icons.phone_android; + case 'laptop': + return Icons.laptop; + case 'tablet': + return Icons.tablet_android; + case 'server': + return Icons.dns; + case 'router': + return Icons.router; + case 'tv': + return Icons.tv; + case 'printer': + return Icons.print; + default: + return Icons.device_unknown; + } + } + + String _emptyMessage() { + switch (_filter) { + case _DeviceFilter.newDevices: + return 'No new devices'; + case _DeviceFilter.registered: + return 'No registered devices'; + case _DeviceFilter.all: + return 'No devices found'; + } + } + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final formatter = FriendlyDateFormatter(); + + return Scaffold( + appBar: AppBar(title: const Text('Devices')), + body: Column( + children: [ + Container( + height: 50, + alignment: Alignment.centerRight, + child: Wrap( + spacing: 8.0, + children: [ + ChoiceChip( + label: const Text('New'), + selected: _filter == _DeviceFilter.newDevices, + onSelected: (bool selected) { + setState(() => _filter = _DeviceFilter.newDevices); + _loadDevices(); + }, + ), + ChoiceChip( + label: const Text('Registered'), + selected: _filter == _DeviceFilter.registered, + onSelected: (bool selected) { + setState(() => _filter = _DeviceFilter.registered); + _loadDevices(); + }, + ), + ChoiceChip( + label: const Text('All'), + selected: _filter == _DeviceFilter.all, + onSelected: (bool selected) { + setState(() => _filter = _DeviceFilter.all); + _loadDevices(); + }, + ), + ], + ), + ), + Expanded( + child: _isLoading + ? const Center(child: CircularProgressIndicator()) + : _error != null + ? Center(child: Text('Error: $_error')) + : _devices.isEmpty + ? Center(child: Text(_emptyMessage())) + : RefreshIndicator( + onRefresh: _loadDevices, + child: ListView.builder( + physics: const AlwaysScrollableScrollPhysics(), + itemCount: _devices.length, + itemBuilder: (context, index) { + final device = _devices[index]; + return Stack( + children: [ + Card( + color: device.isRegistered + ? null + : theme.colorScheme.secondaryContainer, + child: ListTile( + leading: Icon(_deviceIcon(device.deviceType)), + title: Text(device.ipv4Address), + subtitle: Text( + '${device.vendor} ยท ${device.macAddress}\n' + 'Last seen: ${formatter.format(device.lastSeen)}', + ), + isThreeLine: true, + trailing: device.isRegistered + ? Text(device.owner) + : null, + ), + ), + if (!device.isRegistered) + Positioned( + top: 16, + right: 16, + child: const StatusBadge( + label: 'New', + color: BadgeColor.secondary, + ), + ), + ], + ); + }, + ), + ), + ), + ], + ), + ); + } +} diff --git a/frontend/lib/model/device.dart b/frontend/lib/model/device.dart new file mode 100644 index 0000000..4fa80fa --- /dev/null +++ b/frontend/lib/model/device.dart @@ -0,0 +1,28 @@ +class Device { + final String macAddress; + final String ipv4Address; + final String vendor; + final DateTime lastSeen; + final bool isRegistered; + final String owner; + final String deviceType; + + Device({ + required this.macAddress, + required this.ipv4Address, + required this.vendor, + required this.lastSeen, + required this.isRegistered, + required this.owner, + required this.deviceType, + }); + + Device.fromJson(Map json) + : macAddress = json['mac_address'] as String, + ipv4Address = json['ipv4_address'] as String, + vendor = json['vendor'] as String, + lastSeen = DateTime.parse(json['last_seen'] as String), + isRegistered = json['is_registered'] as bool, + owner = json['owner'] as String, + deviceType = json['device_type'] as String; +} diff --git a/frontend/lib/navigation.dart b/frontend/lib/navigation.dart index 311d0c8..2d22cfa 100644 --- a/frontend/lib/navigation.dart +++ b/frontend/lib/navigation.dart @@ -1,6 +1,7 @@ import 'package:flutter/material.dart'; import 'package:frontend/settings/settings.dart'; import 'package:go_router/go_router.dart'; +import 'devices/device_list.dart'; import 'notifications/notification_list.dart'; import 'utils/pref_utils.dart'; @@ -23,7 +24,7 @@ final GoRouter router = GoRouter( GoRoute( path: '/devices', name: 'devices', - builder: (context, state) => const Placeholder(), + builder: (context, state) => const DeviceList(), redirect: (context, state) => _redirectToSettings(), ), GoRoute( diff --git a/frontend/lib/utils/oott_api.dart b/frontend/lib/utils/oott_api.dart index df69cef..af3733c 100644 --- a/frontend/lib/utils/oott_api.dart +++ b/frontend/lib/utils/oott_api.dart @@ -4,6 +4,7 @@ import 'package:dio/dio.dart'; import 'package:encrypter/encrypter/xor.dart'; import 'package:flutter/foundation.dart'; import 'package:frontend/utils/pref_utils.dart'; +import '../model/device.dart'; import '../model/notification.dart'; class BackendAPI { @@ -82,6 +83,21 @@ class BackendAPI { await _dio.post('/notifications/mark_all_as_old'); } + Future> listDevices({bool? isRegistered}) async { + debugPrint('About to call /devices'); + + final params = {}; + if (isRegistered != null) params['is_registered'] = isRegistered; + + final response = await _dio.get('/devices', queryParameters: params); + + debugPrint('Received: ${response.data}'); + + return (response.data as List) + .map((item) => Device.fromJson(item as Map)) + .toList(); + } + Future> listNotifications(bool? isNew, int offset) async { debugPrint('About to call /notifications'); diff --git a/frontend/lib/widgets/status_badge.dart b/frontend/lib/widgets/status_badge.dart new file mode 100644 index 0000000..15c0cb1 --- /dev/null +++ b/frontend/lib/widgets/status_badge.dart @@ -0,0 +1,47 @@ +import 'package:flutter/material.dart'; + +import '../theme/app_colors.dart'; + +enum BadgeColor { primary, secondary, tertiary, error, success } + +class StatusBadge extends StatelessWidget { + final String label; + final BadgeColor color; + + const StatusBadge({super.key, required this.label, required this.color}); + + (Color, Color) _resolveColors(ThemeData theme) { + final scheme = theme.colorScheme; + switch (color) { + case BadgeColor.primary: + return (scheme.primary, scheme.onPrimary); + case BadgeColor.secondary: + return (scheme.secondary, scheme.onSecondary); + case BadgeColor.tertiary: + return (scheme.tertiary, scheme.onTertiary); + case BadgeColor.error: + return (scheme.error, scheme.onError); + case BadgeColor.success: + final ext = theme.extension()!; + return (ext.success, ext.onSuccess); + } + } + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final (bg, fg) = _resolveColors(theme); + + return Container( + padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2), + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(4), + color: bg, + ), + child: Text( + label, + style: theme.textTheme.labelSmall?.copyWith(color: fg), + ), + ); + } +}