diff --git a/lib/pages/devices_tab_page.dart b/lib/pages/devices_tab_page.dart index c7816a2..6246efb 100644 --- a/lib/pages/devices_tab_page.dart +++ b/lib/pages/devices_tab_page.dart @@ -1,12 +1,18 @@ -import 'package:abawo_bt_app/pages/home_page.dart'; +import 'package:abawo_bt_app/controller/bluetooth.dart'; +import 'package:abawo_bt_app/database/database.dart'; +import 'package:abawo_bt_app/model/bluetooth_device_model.dart'; import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:go_router/go_router.dart'; -class DevicesTabPage extends StatelessWidget { +class DevicesTabPage extends ConsumerWidget { const DevicesTabPage({super.key}); @override - Widget build(BuildContext context) { + Widget build(BuildContext context, WidgetRef ref) { + final devicesAsync = ref.watch(nConnectedDevicesProvider); + final connectionData = ref.watch(connectionStatusProvider).valueOrNull; + return ListView( padding: const EdgeInsets.fromLTRB(20, 16, 20, 24), children: [ @@ -24,7 +30,7 @@ class DevicesTabPage extends StatelessWidget { ), const SizedBox(height: 6), Text( - 'Manage and connect your hardware.', + 'Manage connected hardware and jump back into setup.', style: Theme.of(context).textTheme.bodyMedium?.copyWith( color: Theme.of(context) .colorScheme @@ -37,31 +43,588 @@ class DevicesTabPage extends StatelessWidget { ), IconButton.filledTonal( tooltip: 'Connect a device', - onPressed: () => context.go('/connect_device'), + onPressed: () => context.push('/connect_device'), icon: const Icon(Icons.add), ), ], ), const SizedBox(height: 20), - Card( - child: Padding( - padding: const EdgeInsets.fromLTRB(16, 16, 16, 8), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - 'Saved devices', - style: Theme.of(context).textTheme.titleMedium?.copyWith( - fontWeight: FontWeight.w700, - ), - ), - const SizedBox(height: 8), - const DevicesList(), - ], - ), + devicesAsync.when( + loading: () => const _LoadingCard(), + error: (error, _) => _MessageCard( + title: 'Could not load devices', + message: error.toString(), + ), + data: (devices) => _ActiveDeviceCard( + devices: devices, + connectionData: connectionData, ), ), + const SizedBox(height: 20), + Row( + children: [ + Expanded( + child: Text( + 'My Devices', + style: Theme.of(context).textTheme.titleLarge?.copyWith( + fontWeight: FontWeight.w700, + ), + ), + ), + TextButton.icon( + onPressed: () => context.push('/connect_device'), + icon: const Icon(Icons.add_circle_outline), + label: const Text('Add Device'), + ), + ], + ), + const SizedBox(height: 10), + const _SavedDevicesList(), ], ); } } + +class _SavedDevicesList extends ConsumerStatefulWidget { + const _SavedDevicesList(); + + @override + ConsumerState<_SavedDevicesList> createState() => _SavedDevicesListState(); +} + +class _SavedDevicesListState extends ConsumerState<_SavedDevicesList> { + String? _connectingDeviceId; + + Future _removeDevice(ConnectedDevice device) async { + final shouldRemove = await showDialog( + context: context, + builder: (dialogContext) => AlertDialog( + title: const Text('Remove device?'), + content: + Text('Do you want to remove ${device.deviceName} from the app?'), + actions: [ + TextButton( + onPressed: () => Navigator.of(dialogContext).pop(false), + child: const Text('Cancel'), + ), + FilledButton( + onPressed: () => Navigator.of(dialogContext).pop(true), + child: const Text('Remove'), + ), + ], + ), + ); + + if (shouldRemove != true || !mounted) { + return; + } + + final result = await ref + .read(nConnectedDevicesProvider.notifier) + .deleteConnectedDevice(device.id); + + if (!mounted) { + return; + } + + if (result.isErr()) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text('Failed to remove device: ${result.unwrapErr()}'), + ), + ); + return; + } + + if (_connectingDeviceId == device.deviceAddress) { + setState(() { + _connectingDeviceId = null; + }); + } + + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text('${device.deviceName} removed from the app.')), + ); + } + + Future _openDevice(ConnectedDevice device) async { + if (_connectingDeviceId != null) { + return; + } + + setState(() { + _connectingDeviceId = device.deviceAddress; + }); + + try { + final controller = await ref.read(bluetoothProvider.future); + final result = await controller.connectById( + device.deviceAddress, + timeout: const Duration(seconds: 10), + ); + + if (!mounted) { + return; + } + + if (result.isOk()) { + context.push('/device/${device.deviceAddress}'); + } else { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar( + content: Text( + 'Connection failed. Is the device turned on and in range?', + ), + duration: Duration(seconds: 3), + ), + ); + } + } catch (error) { + if (!mounted) { + return; + } + + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text('Error: $error')), + ); + } finally { + if (mounted) { + setState(() { + _connectingDeviceId = null; + }); + } + } + } + + @override + Widget build(BuildContext context) { + final devicesAsync = ref.watch(nConnectedDevicesProvider); + final connectionData = ref.watch(connectionStatusProvider).valueOrNull; + final connectedDeviceId = connectionData?.$2; + final connectionState = connectionData?.$1; + + return devicesAsync.when( + loading: () => const _LoadingCard(), + error: (error, _) => _MessageCard( + title: 'Could not load saved devices', + message: error.toString(), + ), + data: (devices) { + if (devices.isEmpty) { + return _MessageCard( + title: 'No devices yet', + message: 'Add your first shifter to start configuring it.', + actionLabel: 'Connect Device', + onAction: () => context.push('/connect_device'), + ); + } + + return Column( + children: [ + for (final device in devices) ...[ + _SavedDeviceTile( + device: device, + isConnecting: device.deviceAddress == _connectingDeviceId, + isConnected: connectedDeviceId == device.deviceAddress && + connectionState == ConnectionStatus.connected, + onTap: () => _openDevice(device), + onRemove: () => _removeDevice(device), + ), + if (device != devices.last) const SizedBox(height: 12), + ], + const SizedBox(height: 12), + OutlinedButton.icon( + onPressed: () => context.push('/connect_device'), + icon: const Icon(Icons.bluetooth_searching), + label: const Text('Connect Device'), + ), + ], + ); + }, + ); + } +} + +class _ActiveDeviceCard extends StatelessWidget { + const _ActiveDeviceCard({ + required this.devices, + required this.connectionData, + }); + + final List devices; + final (ConnectionStatus, String?)? connectionData; + + @override + Widget build(BuildContext context) { + if (devices.isEmpty) { + return _MessageCard( + title: 'No connected devices yet', + message: 'Your saved shifters will show up here with status and shortcuts.', + actionLabel: 'Connect Device', + onAction: () => context.push('/connect_device'), + ); + } + + final connectedId = connectionData?.$2; + final primaryDevice = connectedId == null + ? devices.first + : devices.firstWhere( + (device) => device.deviceAddress == connectedId, + orElse: () => devices.first, + ); + final isConnected = connectedId == primaryDevice.deviceAddress && + connectionData?.$1 == ConnectionStatus.connected; + + // TODO(yannik): Populate battery, signal, and firmware from real device + // telemetry once these values are exposed in the saved-device overview. + const batteryLabel = '--'; + const signalLabel = 'Ready'; + const firmwareLabel = '--'; + + return Card( + child: Padding( + padding: const EdgeInsets.all(18), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + ClipRRect( + borderRadius: BorderRadius.circular(18), + child: Image.asset( + 'assets/images/shifter-wireframe.png', + width: 96, + height: 72, + fit: BoxFit.cover, + ), + ), + const SizedBox(width: 16), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + primaryDevice.deviceName, + style: + Theme.of(context).textTheme.titleLarge?.copyWith( + fontWeight: FontWeight.w700, + ), + ), + const SizedBox(height: 8), + _StatusChip( + label: isConnected ? 'Connected' : 'Saved device', + color: isConnected + ? const Color(0xFF40C979) + : Theme.of(context).colorScheme.primary, + ), + const SizedBox(height: 10), + Text( + primaryDevice.deviceAddress, + style: Theme.of(context).textTheme.bodySmall?.copyWith( + color: Theme.of(context) + .colorScheme + .onSurface + .withValues(alpha: 0.62), + ), + ), + ], + ), + ), + ], + ), + const SizedBox(height: 18), + Row( + children: const [ + Expanded( + child: _MetricTile( + label: 'Battery', + value: batteryLabel, + icon: Icons.battery_charging_full_rounded, + ), + ), + SizedBox(width: 10), + Expanded( + child: _MetricTile( + label: 'Signal', + value: signalLabel, + icon: Icons.signal_cellular_alt, + ), + ), + SizedBox(width: 10), + Expanded( + child: _MetricTile( + label: 'Firmware', + value: firmwareLabel, + icon: Icons.memory_rounded, + ), + ), + ], + ), + ], + ), + ), + ); + } +} + +class _SavedDeviceTile extends StatelessWidget { + const _SavedDeviceTile({ + required this.device, + required this.isConnecting, + required this.isConnected, + required this.onTap, + required this.onRemove, + }); + + final ConnectedDevice device; + final bool isConnecting; + final bool isConnected; + final VoidCallback onTap; + final VoidCallback onRemove; + + @override + Widget build(BuildContext context) { + final colorScheme = Theme.of(context).colorScheme; + + return Material( + color: colorScheme.surface, + borderRadius: BorderRadius.circular(22), + child: InkWell( + borderRadius: BorderRadius.circular(22), + onTap: isConnecting ? null : onTap, + child: Container( + padding: const EdgeInsets.all(16), + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(22), + border: Border.all( + color: isConnected + ? colorScheme.primary.withValues(alpha: 0.65) + : colorScheme.outlineVariant.withValues(alpha: 0.55), + ), + ), + child: Row( + children: [ + Container( + width: 48, + height: 48, + decoration: BoxDecoration( + shape: BoxShape.circle, + color: isConnected + ? colorScheme.primary.withValues(alpha: 0.14) + : colorScheme.surfaceContainerHighest.withValues(alpha: 0.7), + ), + child: Icon( + deviceTypeFromString(device.deviceType) == + DeviceType.universalShifters + ? Icons.bluetooth_rounded + : Icons.memory_rounded, + color: isConnected ? colorScheme.primary : colorScheme.onSurface, + ), + ), + const SizedBox(width: 14), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + device.deviceName, + style: Theme.of(context).textTheme.titleMedium?.copyWith( + fontWeight: FontWeight.w700, + ), + ), + const SizedBox(height: 4), + Text( + isConnected ? 'Connected' : 'Saved device', + style: Theme.of(context).textTheme.bodyMedium?.copyWith( + color: isConnected + ? const Color(0xFF40C979) + : colorScheme.primary, + fontWeight: FontWeight.w600, + ), + ), + const SizedBox(height: 4), + Text( + device.deviceAddress, + style: Theme.of(context).textTheme.bodySmall?.copyWith( + color: colorScheme.onSurface.withValues(alpha: 0.62), + ), + ), + ], + ), + ), + if (isConnecting) + const SizedBox( + width: 20, + height: 20, + child: CircularProgressIndicator(strokeWidth: 2.4), + ) + else + Row( + mainAxisSize: MainAxisSize.min, + children: [ + IconButton( + tooltip: 'Remove device', + onPressed: onRemove, + icon: const Icon(Icons.delete_outline), + ), + Icon( + Icons.chevron_right_rounded, + color: colorScheme.onSurface.withValues(alpha: 0.55), + ), + ], + ), + ], + ), + ), + ), + ); + } +} + +class _MetricTile extends StatelessWidget { + const _MetricTile({ + required this.label, + required this.value, + required this.icon, + }); + + final String label; + final String value; + final IconData icon; + + @override + Widget build(BuildContext context) { + final colorScheme = Theme.of(context).colorScheme; + + return Container( + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 12), + decoration: BoxDecoration( + color: colorScheme.surfaceContainerHighest.withValues(alpha: 0.54), + borderRadius: BorderRadius.circular(16), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Icon(icon, size: 18, color: colorScheme.primary), + const SizedBox(height: 10), + Text( + label, + style: Theme.of(context).textTheme.bodySmall?.copyWith( + color: colorScheme.onSurface.withValues(alpha: 0.62), + ), + ), + const SizedBox(height: 2), + Text( + value, + style: Theme.of(context).textTheme.titleMedium?.copyWith( + fontWeight: FontWeight.w700, + ), + ), + ], + ), + ); + } +} + +class _MessageCard extends StatelessWidget { + const _MessageCard({ + required this.title, + required this.message, + this.actionLabel, + this.onAction, + }); + + final String title; + final String message; + final String? actionLabel; + final VoidCallback? onAction; + + @override + Widget build(BuildContext context) { + return Card( + child: Padding( + padding: const EdgeInsets.all(18), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + title, + style: Theme.of(context).textTheme.titleMedium?.copyWith( + fontWeight: FontWeight.w700, + ), + ), + const SizedBox(height: 8), + Text( + message, + style: Theme.of(context).textTheme.bodyMedium?.copyWith( + color: Theme.of(context) + .colorScheme + .onSurface + .withValues(alpha: 0.68), + ), + ), + if (actionLabel != null && onAction != null) ...[ + const SizedBox(height: 14), + FilledButton.icon( + onPressed: onAction, + icon: const Icon(Icons.add_circle_outline), + label: Text(actionLabel!), + ), + ], + ], + ), + ), + ); + } +} + +class _LoadingCard extends StatelessWidget { + const _LoadingCard(); + + @override + Widget build(BuildContext context) { + return const Card( + child: Padding( + padding: EdgeInsets.all(28), + child: Center(child: CircularProgressIndicator()), + ), + ); + } +} + +class _StatusChip extends StatelessWidget { + const _StatusChip({ + required this.label, + required this.color, + }); + + final String label; + final Color color; + + @override + Widget build(BuildContext context) { + return Container( + padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 6), + decoration: BoxDecoration( + color: color.withValues(alpha: 0.12), + borderRadius: BorderRadius.circular(999), + border: Border.all(color: color.withValues(alpha: 0.24)), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(Icons.check_circle_outline, size: 14, color: color), + const SizedBox(width: 6), + Text( + label, + style: Theme.of(context).textTheme.bodySmall?.copyWith( + color: color, + fontWeight: FontWeight.w700, + ), + ), + ], + ), + ); + } +} diff --git a/lib/pages/settings_page.dart b/lib/pages/settings_page.dart index a6e3266..0894c28 100644 --- a/lib/pages/settings_page.dart +++ b/lib/pages/settings_page.dart @@ -1,10 +1,14 @@ +import 'package:abawo_bt_app/util/sharedPrefs.dart'; import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; -class SettingsPage extends StatelessWidget { +class SettingsPage extends ConsumerWidget { const SettingsPage({super.key}); @override - Widget build(BuildContext context) { + Widget build(BuildContext context, WidgetRef ref) { + final themePreference = ref.watch(appThemePreferenceProvider); + return ListView( padding: const EdgeInsets.fromLTRB(20, 16, 20, 24), children: [ @@ -25,25 +29,89 @@ class SettingsPage extends StatelessWidget { ), ), const SizedBox(height: 20), + Card( + child: Padding( + padding: const EdgeInsets.all(18), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'Appearance', + style: Theme.of(context).textTheme.titleLarge?.copyWith( + fontWeight: FontWeight.w700, + ), + ), + const SizedBox(height: 8), + Text( + 'Choose whether the app follows the system theme or stays in a fixed mode.', + style: Theme.of(context).textTheme.bodyMedium?.copyWith( + color: Theme.of(context) + .colorScheme + .onSurface + .withValues(alpha: 0.68), + ), + ), + const SizedBox(height: 16), + SegmentedButton( + multiSelectionEnabled: false, + selected: {themePreference}, + segments: const [ + ButtonSegment( + value: AppThemePreference.system, + icon: Icon(Icons.brightness_auto_rounded), + label: Text('System'), + ), + ButtonSegment( + value: AppThemePreference.light, + icon: Icon(Icons.light_mode_rounded), + label: Text('Light'), + ), + ButtonSegment( + value: AppThemePreference.dark, + icon: Icon(Icons.dark_mode_rounded), + label: Text('Dark'), + ), + ], + onSelectionChanged: (selection) { + ref + .read(appThemePreferenceProvider.notifier) + .update(selection.first); + }, + ), + ], + ), + ), + ), + const SizedBox(height: 16), Card( child: Column( - children: const [ + children: [ ListTile( - leading: Icon(Icons.brightness_6), - title: Text('Theme'), - subtitle: Text('Theme controls arrive in the next phase'), + leading: const Icon(Icons.bluetooth_searching_rounded), + title: const Text('Bluetooth'), + subtitle: const Text('Manage connections and pairing behavior'), + trailing: Text( + 'Enabled', + style: Theme.of(context).textTheme.bodySmall?.copyWith( + color: Theme.of(context).colorScheme.primary, + fontWeight: FontWeight.w700, + ), + ), ), - Divider(height: 1), + const Divider(height: 1), ListTile( - leading: Icon(Icons.bluetooth), - title: Text('Bluetooth Settings'), - subtitle: Text('Configure Bluetooth connections'), - ), - Divider(height: 1), - ListTile( - leading: Icon(Icons.info), - title: Text('About'), - subtitle: Text('App information'), + leading: const Icon(Icons.info_outline_rounded), + title: const Text('About'), + subtitle: const Text('Version, credits, and legal information'), + trailing: const Icon(Icons.chevron_right_rounded), + onTap: () { + showAboutDialog( + context: context, + applicationName: 'Abawo BT App', + applicationVersion: '1.0.0', + applicationLegalese: 'abawo Bluetooth control and setup app', + ); + }, ), ], ),