diff --git a/open_wearable/lib/widgets/devices/connect_devices_page.dart b/open_wearable/lib/widgets/devices/connect_devices_page.dart index 7608a9f0..8cd02c25 100644 --- a/open_wearable/lib/widgets/devices/connect_devices_page.dart +++ b/open_wearable/lib/widgets/devices/connect_devices_page.dart @@ -34,6 +34,8 @@ class ConnectDevicesPage extends StatefulWidget { class _ConnectDevicesPageState extends State { bool _hasBlePermissions = false; bool _hasMicPermission = true; + bool _showOnlyOpenEarableDevices = false; + String _deviceNameFilterText = 'OpenEarable'; final Map _connectingDevices = {}; late ConnectDevicesScanSnapshot _scanSnapshot; @@ -134,12 +136,17 @@ class _ConnectDevicesPageState extends State { .where((device) => !connectedDeviceIds.contains(device.id)) .toList(); final thisDeviceEntry = _thisDeviceEntry; - final availableDevices = [ + final allAvailableDevices = [ if (thisDeviceEntry != null && !connectedDeviceIds.contains(thisDeviceEntry.id)) thisDeviceEntry, ...scannedDevices.where((device) => device.id != thisDeviceEntry?.id), ]; + final availableDevices = _showOnlyOpenEarableDevices + ? allAvailableDevices + .where((device) => device.name.contains(_deviceNameFilterText)) + .toList() + : allAvailableDevices; return PlatformScaffold( appBar: PlatformAppBar( @@ -249,16 +256,15 @@ class _ConnectDevicesPageState extends State { context, title: 'Available', count: availableDevices.length, + trailing: _buildOpenEarableFilterToggle(context), ), if (availableDevices.isEmpty) _buildEmptyCard( context, title: _scanSnapshot.isScanning ? 'Scanning for devices...' - : 'No devices found yet', - subtitle: _scanSnapshot.isScanning - ? 'Make sure your wearable is turned on and nearby.' - : 'Press scan again or pull to refresh.', + : 'No devices found', + subtitle: _emptyAvailableDevicesMessage(), ) else ...availableDevices.map((device) { @@ -368,6 +374,7 @@ class _ConnectDevicesPageState extends State { BuildContext context, { required String title, required int count, + Widget? trailing, }) { return Padding( padding: const EdgeInsets.fromLTRB(4, 0, 4, 8), @@ -381,6 +388,18 @@ class _ConnectDevicesPageState extends State { ), const SizedBox(width: 8), _StatusPill(label: '$count'), + if (trailing != null) ...[ + const SizedBox(width: 8), + Expanded( + child: Align( + alignment: Alignment.centerRight, + child: FittedBox( + fit: BoxFit.scaleDown, + child: trailing, + ), + ), + ), + ], ], ), ); @@ -401,6 +420,98 @@ class _ConnectDevicesPageState extends State { ); } + Widget _buildOpenEarableFilterToggle(BuildContext context) { + final theme = Theme.of(context); + final colorScheme = theme.colorScheme; + + return Row( + mainAxisSize: MainAxisSize.min, + children: [ + Text( + 'Device name filter', + style: theme.textTheme.labelSmall?.copyWith( + fontWeight: FontWeight.w600, + ), + ), + const SizedBox(width: 6), + InkWell( + borderRadius: BorderRadius.circular(999), + onTap: _editDeviceNameFilterText, + child: Container( + constraints: const BoxConstraints(minWidth: 78, maxWidth: 128), + padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 4), + decoration: BoxDecoration( + color: Colors.transparent, + borderRadius: BorderRadius.circular(999), + border: Border.all( + color: colorScheme.outlineVariant.withValues(alpha: 0.9), + ), + ), + child: Text( + _deviceNameFilterText, + maxLines: 1, + overflow: TextOverflow.ellipsis, + textAlign: TextAlign.center, + style: theme.textTheme.labelSmall?.copyWith( + color: colorScheme.onSurface, + fontWeight: FontWeight.w700, + ), + ), + ), + ), + const SizedBox(width: 2), + Transform.scale( + scale: 0.75, + child: Switch.adaptive( + value: _showOnlyOpenEarableDevices, + onChanged: (value) { + setState(() { + _showOnlyOpenEarableDevices = value; + }); + }, + ), + ), + ], + ); + } + + Future _editDeviceNameFilterText() async { + var editedText = _deviceNameFilterText; + final nextText = await showPlatformDialog( + context: context, + builder: (dialogContext) => PlatformAlertDialog( + title: const Text('Device name filter'), + content: TextFormField( + initialValue: editedText, + autofocus: true, + decoration: const InputDecoration(labelText: 'Name contains'), + textInputAction: TextInputAction.done, + onChanged: (value) => editedText = value, + onFieldSubmitted: (value) => Navigator.of(dialogContext).pop(value), + ), + actions: [ + PlatformDialogAction( + onPressed: () => Navigator.of(dialogContext).pop(), + child: const Text('Cancel'), + ), + PlatformDialogAction( + onPressed: () => Navigator.of(dialogContext).pop(editedText), + child: const Text('Apply'), + ), + ], + ), + ); + + final trimmedText = nextText?.trim(); + if (trimmedText == null || trimmedText.isEmpty || !mounted) { + return; + } + + setState(() { + _deviceNameFilterText = trimmedText; + }); + } + Widget _buildTrailingWidget( DiscoveredDevice device, { required VoidCallback onConnect, @@ -446,6 +557,15 @@ class _ConnectDevicesPageState extends State { return '${elapsed.inHours}h ago'; } + String _emptyAvailableDevicesMessage() { + if (_showOnlyOpenEarableDevices) { + return 'No devices match "$_deviceNameFilterText".'; + } + return _scanSnapshot.isScanning + ? 'Make sure your wearable is turned on and nearby.' + : 'Press scan again or pull to refresh.'; + } + Future _addThisDeviceToDiscovered() async { if (_thisDeviceEntry != null) return; final profile = await DeviceProfile.fetch();