diff --git a/example/CHANGELOG.md b/example/CHANGELOG.md index 71591b29..f45df107 100644 --- a/example/CHANGELOG.md +++ b/example/CHANGELOG.md @@ -1,20 +1,32 @@ -## 1.2.0 -* Improved scan button visibility - converted to prominent filled button with text label -* Enhanced "no devices found" state with explicit "Start Scan" call-to-action button -* Moved search field to app bar header for better accessibility -* Moved queue type settings to drawer menu as expandable section -* Added tooltip to Bluetooth availability icon (tap to view on mobile) -* Display company name based on company identifier from manufacturer data -* Enhanced search functionality - now supports searching by company name -* Improved overall UI layout and navigation flow - ## 1.1.0 -* Add support for `autoConnect` parameter -* Display RSSI values in device details -* Persist filters -* Fix clear log button -* Move "Copy Services" button to Services panel header -* Enhance services format to be more detailed and human-readable +* **Services & Characteristics:** + * Add property filtering for characteristics with visual filter chips + * Add navigation buttons to navigate between characteristics (previous/next) + * Improve service sorting (favorites first, system services last) + * Enhance services list UI with better filtering and navigation + * Improve format for discovered services to be more detailed and human-readable + * Move "Copy Services" button to Services panel header + +* **Company & Manufacturer Data:** + * Display company name based on company identifier from manufacturer data + * Show and filter by company name in device list + * Enhanced search functionality - now supports searching by company name + +* **Scanning & Device Discovery:** + * Improved scan button visibility - converted to prominent filled button with text label + * Enhanced "no devices found" state with explicit "Start Scan" call-to-action button + * Display RSSI values in device details + +* **UI & Navigation:** + * Moved search field to app bar header for better accessibility + * Moved queue type settings to drawer menu as expandable section + * Added tooltip to Bluetooth availability icon (tap to view on mobile) + * Improved overall UI layout and navigation flow + +* **Functionality:** + * Add support for `autoConnect` parameter + * Persist filters across app sessions + * Fix clear log button functionality ## 1.0.0 * Initial release \ No newline at end of file diff --git a/example/lib/data/utils.dart b/example/lib/data/utils.dart index 1900dada..447b6e1e 100644 --- a/example/lib/data/utils.dart +++ b/example/lib/data/utils.dart @@ -1,3 +1,5 @@ +import 'package:universal_ble/universal_ble.dart'; + bool isSystemService(String uuid) { final normalized = uuid.toUpperCase().replaceAll('-', ''); return normalized == '00001800' || @@ -5,3 +7,98 @@ bool isSystemService(String uuid) { normalized == '0000180A' || normalized.startsWith('000018'); } + +/// Sorts BLE services with the following priority: +/// 1. Favorite services first +/// 2. System services last +/// 3. Other services in between +List sortBleServices( + List services, { + Set? favoriteServices, +}) { + final sortedServices = List.from(services); + sortedServices.sort((a, b) { + final aIsFavorite = favoriteServices?.contains(a.uuid) ?? false; + final bIsFavorite = favoriteServices?.contains(b.uuid) ?? false; + if (aIsFavorite != bIsFavorite) { + return aIsFavorite ? -1 : 1; + } + final aIsSystem = isSystemService(a.uuid); + final bIsSystem = isSystemService(b.uuid); + if (aIsSystem != bIsSystem) { + return aIsSystem ? 1 : -1; + } + return 0; + }); + return sortedServices; +} + +/// Returns a list of all filtered characteristics with their parent services. +/// Services are sorted (favorites first, system services last). +/// Characteristics are filtered by property filters if provided. +List<({BleService service, BleCharacteristic characteristic})> + getFilteredBleCharacteristics( + List services, { + Set? favoriteServices, + Set? propertyFilters, +}) { + final List<({BleService service, BleCharacteristic characteristic})> result = + []; + + // Sort services: favorites first, then system services, then others + final sortedServices = sortBleServices( + services, + favoriteServices: favoriteServices, + ); + + for (var service in sortedServices) { + for (var char in service.characteristics) { + // Filter by properties if filters are selected + if (propertyFilters != null && propertyFilters.isNotEmpty) { + if (char.properties.any((prop) => propertyFilters.contains(prop))) { + result.add((service: service, characteristic: char)); + } + } else { + result.add((service: service, characteristic: char)); + } + } + } + return result; +} + +/// Finds the next or previous characteristic in a filtered list. +/// +/// [filtered] - The filtered list of (service, characteristic) tuples +/// [currentCharacteristicUuid] - The UUID of the currently selected characteristic +/// [next] - If true, finds the next item; if false, finds the previous item +/// +/// Returns the next/previous item, or the first item if current is not found, +/// or null if the list is empty. +({BleService service, BleCharacteristic characteristic})? + navigateToAdjacentCharacteristic( + List<({BleService service, BleCharacteristic characteristic})> filtered, + String currentCharacteristicUuid, + bool next, +) { + if (filtered.isEmpty) return null; + + final currentIndex = filtered.indexWhere( + (item) => item.characteristic.uuid == currentCharacteristicUuid, + ); + + if (currentIndex == -1) { + // Current selection not in filtered list, return first + return filtered.first; + } + + if (next) { + // Navigate to next (with wrapping) + final nextIndex = (currentIndex + 1) % filtered.length; + return filtered[nextIndex]; + } else { + // Navigate to previous (with wrapping) + final previousIndex = + currentIndex > 0 ? currentIndex - 1 : filtered.length - 1; + return filtered[previousIndex]; + } +} diff --git a/example/lib/home/scanner_screen.dart b/example/lib/home/scanner_screen.dart index 064cb6c5..ae52dc60 100644 --- a/example/lib/home/scanner_screen.dart +++ b/example/lib/home/scanner_screen.dart @@ -51,7 +51,17 @@ class _ScannerScreenState extends State { (isScanning) => setState(() => _isScanning = isScanning), ); - _loadScanFilters(); + // Get initial Bluetooth availability state + UniversalBle.getBluetoothAvailabilityState().then((state) { + if (mounted) { + setState(() => bleAvailabilityState = state); + } + }); + + _loadScanFilters().then((_) { + // Auto-start scanning after filters are loaded + _tryAutoStartScan(); + }); // Load company identifiers in the background CompanyIdentifierService.instance.load(); @@ -305,6 +315,17 @@ class _ScannerScreenState extends State { bool get _isBluetoothAvailable => bleAvailabilityState == AvailabilityState.poweredOn; + Future _tryAutoStartScan() async { + // Only auto-start if Bluetooth is available and not already scanning + if (_isBluetoothAvailable && !_isScanning) { + // Check again to make sure we're not already scanning + final isScanning = await UniversalBle.isScanning(); + if (!isScanning && mounted) { + await _startScan(); + } + } + } + String _getBluetoothAvailabilityTooltip() { switch (bleAvailabilityState) { case AvailabilityState.poweredOn: @@ -410,6 +431,10 @@ class _ScannerScreenState extends State { triggerMode: TooltipTriggerMode.tap, child: BleAvailabilityIcon(onAvailabilityStateChanged: (state) { setState(() => bleAvailabilityState = state); + // Auto-start scanning when Bluetooth becomes available + if (state == AvailabilityState.poweredOn) { + _tryAutoStartScan(); + } }), ), ], diff --git a/example/lib/peripheral_details/peripheral_detail_page.dart b/example/lib/peripheral_details/peripheral_detail_page.dart index 73b98958..912a33cd 100644 --- a/example/lib/peripheral_details/peripheral_detail_page.dart +++ b/example/lib/peripheral_details/peripheral_detail_page.dart @@ -6,6 +6,7 @@ import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:universal_ble/universal_ble.dart'; import 'package:universal_ble_example/data/storage_service.dart'; +import 'package:universal_ble_example/data/utils.dart'; import 'package:universal_ble_example/peripheral_details/widgets/result_widget.dart'; import 'package:universal_ble_example/peripheral_details/widgets/services_list_widget.dart'; import 'package:universal_ble_example/peripheral_details/widgets/services_side_widget.dart'; @@ -30,6 +31,7 @@ class _PeripheralDetailPageState extends State { final List _logs = []; final binaryCode = TextEditingController(); bool _isLoading = false; + bool _isDiscoveringServices = false; bool _isDeviceInfoExpanded = false; bool _isDeviceActionsExpanded = true; final Map _subscribedCharacteristics = {}; @@ -41,6 +43,7 @@ class _PeripheralDetailPageState extends State { BleCharacteristic? selectedCharacteristic; final ScrollController _logsScrollController = ScrollController(); final Set _favoriteServices = {}; + Set? _currentPropertyFilters; void _loadFavoriteServices() { final favorites = StorageService.instance.getFavoriteServices(); @@ -105,12 +108,15 @@ class _PeripheralDetailPageState extends State { Uint8List value, int? timestamp, ) { - String s = String.fromCharCodes(value); - String data = '$s\nraw : ${value.toString()}'; + String data = _formatReadValue(value); DateTime? timestampDateTime = timestamp != null ? DateTime.fromMillisecondsSinceEpoch(timestamp) : null; - debugPrint('_handleValueChange ($timestampDateTime) $characteristicId, $s'); + // Extract hex for debug print (format: (0x...)) + String formattedHex = + '(0x${value.map((b) => b.toRadixString(16).padLeft(2, '0')).join()})'; + debugPrint( + '_handleValueChange ($timestampDateTime) $characteristicId, $formattedHex'); _addLog("Value", data); } @@ -123,6 +129,10 @@ class _PeripheralDetailPageState extends State { const webWarning = "Note: Only services added in ScanFilter or WebOptions will be discovered"; + setState(() { + _isDiscoveringServices = true; + }); + await _executeWithLoading( () async { var services = await bleDevice.discoverServices(withDescriptors: false); @@ -151,6 +161,41 @@ class _PeripheralDetailPageState extends State { _addLog("DiscoverServicesError", errorMessage.toString()); }, ); + + setState(() { + _isDiscoveringServices = false; + }); + } + + String _formatReadValue(Uint8List value) { + String formattedHex = + '(0x${value.map((b) => b.toRadixString(16).padLeft(2, '0')).join()})'; + String stringValue = ''; + try { + // Find the first null byte (0x00) to handle null-terminated strings + int nullIndex = value.indexOf(0); + Uint8List stringBytes = + nullIndex >= 0 ? value.sublist(0, nullIndex) : value; + + if (stringBytes.isNotEmpty) { + stringValue = String.fromCharCodes(stringBytes); + // Check if it's a valid printable string (not just control characters) + // Allow tab, newline, carriage return + if (stringValue.isNotEmpty && + !stringValue.codeUnits.every((code) => + (code >= 32 && code <= 126) || + code == 9 || + code == 10 || + code == 13)) { + stringValue = ''; + } + } + } catch (e) { + // Not a valid string, leave empty + } + return stringValue.isNotEmpty + ? '"$stringValue" $formattedHex\nraw: ${value.toString()}' + : '$formattedHex\nraw: ${value.toString()}'; } Future _readValue() async { @@ -159,8 +204,7 @@ class _PeripheralDetailPageState extends State { await _executeWithLoading( () async { Uint8List value = await selectedCharacteristic.read(); - String s = String.fromCharCodes(value); - String data = '$s\nraw : ${value.toString()}'; + String data = _formatReadValue(value); _addLog('Read', data); }, onError: (error) { @@ -278,6 +322,46 @@ class _PeripheralDetailPageState extends State { ); } + Future _readAllCharacteristics() async { + if (!isConnected || discoveredServices.isEmpty) return; + await _executeWithLoading( + () async { + int successCount = 0; + int errorCount = 0; + for (var service in discoveredServices) { + for (var characteristic in service.characteristics) { + if (characteristic.properties + .contains(CharacteristicProperty.read)) { + try { + Uint8List value = await characteristic.read(); + String data = _formatReadValue(value); + _addLog( + 'ReadAll', + '${service.uuid}/${characteristic.uuid}: $data', + ); + successCount++; + } catch (e) { + errorCount++; + _addLog( + 'ReadAllError', + '${service.uuid}/${characteristic.uuid}: $e', + ); + debugPrint('Failed to read ${characteristic.uuid}: $e'); + } + } + } + } + _addLog( + 'ReadAll', + 'Completed: $successCount successful${errorCount > 0 ? ', $errorCount failed' : ''}', + ); + }, + onError: (error) { + _addLog('ReadAllCharacteristicsError', error); + }, + ); + } + CharacteristicSubscription? _getCharacteristicSubscription( BleCharacteristic characteristic, ) { @@ -320,9 +404,35 @@ class _PeripheralDetailPageState extends State { padding: EdgeInsets.only(bottom: 10.0), child: ServicesSideWidget( discoveredServices: discoveredServices, - serviceListBuilder: () => _buildServicesList(onSelect: (_, __) { - Navigator.pop(context); - }), + selectedService: selectedService, + selectedCharacteristic: selectedCharacteristic, + initialPropertyFilters: _currentPropertyFilters, + isDiscoveringServices: _isDiscoveringServices, + serviceListBuilder: + (propertyFilters, listKey, isDiscoveringServices) => + _buildServicesList( + onSelect: (service, characteristic) { + setState(() { + selectedService = service; + selectedCharacteristic = characteristic; + }); + Navigator.pop(context); + }, + propertyFilters: propertyFilters, + listKey: listKey, + isDiscoveringServices: isDiscoveringServices, + ), + onCharacteristicSelected: (service, characteristic) { + setState(() { + selectedService = service; + selectedCharacteristic = characteristic; + }); + }, + onPropertyFiltersChanged: (propertyFilters) { + setState(() { + _currentPropertyFilters = propertyFilters; + }); + }, onCopyServices: discoveredServices.isNotEmpty ? () async { await _copyServicesToClipboard(); @@ -472,7 +582,28 @@ class _PeripheralDetailPageState extends State { flex: 1, child: ServicesSideWidget( discoveredServices: discoveredServices, - serviceListBuilder: _buildServicesList, + selectedService: selectedService, + selectedCharacteristic: selectedCharacteristic, + initialPropertyFilters: _currentPropertyFilters, + isDiscoveringServices: _isDiscoveringServices, + serviceListBuilder: + (propertyFilters, listKey, isDiscoveringServices) => + _buildServicesList( + propertyFilters: propertyFilters, + listKey: listKey, + isDiscoveringServices: isDiscoveringServices, + ), + onCharacteristicSelected: (service, characteristic) { + setState(() { + selectedService = service; + selectedCharacteristic = characteristic; + }); + }, + onPropertyFiltersChanged: (propertyFilters) { + setState(() { + _currentPropertyFilters = propertyFilters; + }); + }, onCopyServices: discoveredServices.isNotEmpty ? _copyServicesToClipboard : null, @@ -540,7 +671,8 @@ class _PeripheralDetailPageState extends State { child: Padding( padding: const EdgeInsets.all(16.0), child: Column( - crossAxisAlignment: CrossAxisAlignment.start, + spacing: 12, + crossAxisAlignment: CrossAxisAlignment.stretch, children: [ Row( children: [ @@ -560,48 +692,103 @@ class _PeripheralDetailPageState extends State { ), ], ), - const SizedBox(height: 16), - Form( - key: valueFormKey, - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - TextFormField( - controller: binaryCode, - enabled: isConnected && - _hasSelectedCharacteristicProperty([ - CharacteristicProperty.write, - CharacteristicProperty.writeWithoutResponse, - ]), - validator: (value) { - if (value == null || value.isEmpty) { - return 'Please enter a value'; - } - try { - hex.decode(binaryCode.text); - return null; - } catch (e) { - return 'Please enter a valid hex value ( without spaces or 0x (e.g. F0BB) )'; - } - }, - decoration: InputDecoration( - hintText: - "Enter Hex values without spaces or 0x (e.g. F0BB)", - border: OutlineInputBorder( + Row( + spacing: 12, + children: [ + Expanded( + child: ElevatedButton.icon( + onPressed: isConnected && + _hasSelectedCharacteristicProperty([ + CharacteristicProperty.read, + ]) + ? _readValue + : null, + icon: const Icon(Icons.download), + label: const Text('Read'), + style: ElevatedButton.styleFrom( + backgroundColor: colorScheme.secondary, + foregroundColor: colorScheme.onSecondary, + padding: const EdgeInsets.symmetric( + vertical: 16, + ), + shape: RoundedRectangleBorder( borderRadius: BorderRadius.circular(12), ), - filled: true, - fillColor: colorScheme.surfaceContainerHighest, - prefixIcon: Icon( - Icons.code, - color: colorScheme.primary, + ), + ), + ), + Expanded( + child: ElevatedButton.icon( + onPressed: isConnected && discoveredServices.isNotEmpty + ? _readAllCharacteristics + : null, + icon: const Icon(Icons.download), + label: const Text('Read All'), + style: ElevatedButton.styleFrom( + backgroundColor: Colors.green, + foregroundColor: Colors.white, + padding: const EdgeInsets.symmetric( + vertical: 16, + ), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(12), ), ), ), - const SizedBox(height: 12), + ), + ], + ), + Form( + key: valueFormKey, + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + spacing: 12, + children: [ Row( + spacing: 12, children: [ Expanded( + flex: 3, + child: TextFormField( + controller: binaryCode, + enabled: isConnected && + _hasSelectedCharacteristicProperty([ + CharacteristicProperty.write, + CharacteristicProperty.writeWithoutResponse, + ]), + validator: (value) { + if (value == null || value.isEmpty) { + return 'Please enter a value'; + } + try { + hex.decode(binaryCode.text); + return null; + } catch (e) { + return 'Please enter a valid hex value ( without spaces or 0x (e.g. F0BB) )'; + } + }, + decoration: InputDecoration( + hintText: + "Enter Hex values without spaces or 0x (e.g. F0BB)", + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(12), + ), + filled: true, + fillColor: colorScheme.surfaceContainerHighest, + isDense: true, + contentPadding: const EdgeInsets.symmetric( + horizontal: 12, + vertical: 12, + ), + prefixIcon: Icon( + Icons.code, + color: colorScheme.primary, + ), + ), + ), + ), + Expanded( + flex: 1, child: ElevatedButton.icon( onPressed: isConnected && _hasSelectedCharacteristicProperty([ @@ -625,35 +812,11 @@ class _PeripheralDetailPageState extends State { ), ), ), - const SizedBox(width: 12), - Expanded( - child: ElevatedButton.icon( - onPressed: isConnected && - _hasSelectedCharacteristicProperty([ - CharacteristicProperty.read, - ]) - ? _readValue - : null, - icon: const Icon(Icons.download), - label: const Text('Read'), - style: ElevatedButton.styleFrom( - backgroundColor: colorScheme.secondary, - foregroundColor: colorScheme.onSecondary, - padding: const EdgeInsets.symmetric( - vertical: 16, - ), - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(12), - ), - ), - ), - ), ], ), ], ), ), - const SizedBox(height: 16), Wrap( spacing: 12, runSpacing: 12, @@ -1218,7 +1381,7 @@ class _PeripheralDetailPageState extends State { ); }, icon: const Icon(Icons.info_outline), - label: const Text('State'), + label: const Text('Connection State'), style: OutlinedButton.styleFrom( foregroundColor: colorScheme.onSurface, padding: const EdgeInsets.symmetric( @@ -1242,7 +1405,7 @@ class _PeripheralDetailPageState extends State { } : null, icon: const Icon(Icons.signal_cellular_alt), - label: const Text('Get RSSI'), + label: const Text('RSSI'), style: OutlinedButton.styleFrom( foregroundColor: colorScheme.onSurface, padding: const EdgeInsets.symmetric( @@ -1324,7 +1487,7 @@ class _PeripheralDetailPageState extends State { ); }, icon: const Icon(Icons.check_circle), - label: const Text('Check Paired'), + label: const Text('Pairing State'), style: OutlinedButton.styleFrom( foregroundColor: colorScheme.onSurface, padding: const EdgeInsets.symmetric( @@ -1336,33 +1499,34 @@ class _PeripheralDetailPageState extends State { ), ), ), - OutlinedButton.icon( - onPressed: () async { - await _executeWithLoading( - () async { - await bleDevice.unpair(); - }, - onError: (error) { - _addLog('UnpairError', error); - }, - ); - }, - icon: const Icon(Icons.link_off), - label: const Text('Unpair'), - style: OutlinedButton.styleFrom( - foregroundColor: colorScheme.error, - side: BorderSide( - color: colorScheme.error, - ), - padding: const EdgeInsets.symmetric( - horizontal: 16, - vertical: 12, - ), - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(8), + if (BleCapabilities.hasSystemPairingApi) + OutlinedButton.icon( + onPressed: () async { + await _executeWithLoading( + () async { + await bleDevice.unpair(); + }, + onError: (error) { + _addLog('UnpairError', error); + }, + ); + }, + icon: const Icon(Icons.link_off), + label: const Text('Unpair'), + style: OutlinedButton.styleFrom( + foregroundColor: colorScheme.error, + side: BorderSide( + color: colorScheme.error, + ), + padding: const EdgeInsets.symmetric( + horizontal: 16, + vertical: 12, + ), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(8), + ), ), ), - ), ], ), ), @@ -1482,14 +1646,20 @@ class _PeripheralDetailPageState extends State { Widget _buildServicesList({ Function(BleService, BleCharacteristic?)? onSelect, + Set? propertyFilters, + GlobalKey? listKey, + bool isDiscoveringServices = false, }) { return ServicesListWidget( + key: listKey, discoveredServices: discoveredServices, selectedService: selectedService, selectedCharacteristic: selectedCharacteristic, favoriteServices: _favoriteServices, subscribedCharacteristics: _subscribedCharacteristics, scrollable: true, + propertyFilters: propertyFilters, + isDiscoveringServices: isDiscoveringServices, onTap: (service, characteristic) { setState(() { selectedService = service; @@ -1512,9 +1682,61 @@ class _PeripheralDetailPageState extends State { ); } + /// Returns a list of all filtered characteristics with their parent services + List<({BleService service, BleCharacteristic characteristic})> + _getFilteredCharacteristics() { + return getFilteredBleCharacteristics( + discoveredServices, + favoriteServices: _favoriteServices, + propertyFilters: _currentPropertyFilters, + ); + } + + void _navigateToPreviousCharacteristic() { + if (selectedCharacteristic == null) return; + + final filtered = _getFilteredCharacteristics(); + final result = navigateToAdjacentCharacteristic( + filtered, + selectedCharacteristic!.uuid, + false, // previous + ); + + if (result != null) { + setState(() { + selectedService = result.service; + selectedCharacteristic = result.characteristic; + }); + } + } + + void _navigateToNextCharacteristic() { + if (selectedCharacteristic == null) return; + + final filtered = _getFilteredCharacteristics(); + final result = navigateToAdjacentCharacteristic( + filtered, + selectedCharacteristic!.uuid, + true, // next + ); + + if (result != null) { + setState(() { + selectedService = result.service; + selectedCharacteristic = result.characteristic; + }); + } + } + + bool _canNavigateCharacteristics() { + final filtered = _getFilteredCharacteristics(); + return filtered.length > 1; + } + Widget _buildSelectedCharacteristicCard() { final colorScheme = Theme.of(context).colorScheme; if (selectedCharacteristic == null) return const SizedBox.shrink(); + final canNavigate = _canNavigateCharacteristics(); return Card( elevation: 2, shape: RoundedRectangleBorder( @@ -1534,7 +1756,7 @@ class _PeripheralDetailPageState extends State { crossAxisAlignment: CrossAxisAlignment.start, children: [ Icon( - Icons.settings, + Icons.apps, size: 16, color: colorScheme.onSurface.withValues(alpha: 0.6), ), @@ -1545,7 +1767,7 @@ class _PeripheralDetailPageState extends State { mainAxisSize: MainAxisSize.min, children: [ Text( - 'Characteristic', + 'Service', style: TextStyle( fontSize: 11, color: colorScheme.onSurface.withValues(alpha: 0.6), @@ -1553,37 +1775,26 @@ class _PeripheralDetailPageState extends State { ), ), const SizedBox(height: 2), - Row( - children: [ - Expanded( - child: InkWell( - onTap: () { - Clipboard.setData(ClipboardData( - text: selectedCharacteristic?.uuid ?? "", - )); - _showSnackBar('Copied to clipboard'); - }, - child: Text( - selectedCharacteristic!.uuid, - style: TextStyle( - fontSize: 12, - fontFamily: 'monospace', - color: colorScheme.onSurface, - fontWeight: FontWeight.w500, - ), - ), - ), + InkWell( + onTap: () { + Clipboard.setData(ClipboardData( + text: selectedService?.uuid ?? "", + )); + _showSnackBar('Copied to clipboard'); + }, + child: Text( + selectedService?.uuid ?? "Unknown", + style: TextStyle( + fontSize: 12, + fontFamily: 'monospace', + color: colorScheme.onSurface, + fontWeight: FontWeight.w500, ), - ], + ), ), ], ), ), - Icon( - Icons.arrow_drop_down, - size: 18, - color: colorScheme.onSurface.withValues(alpha: 0.6), - ), ], ), const SizedBox(height: 8), @@ -1591,7 +1802,7 @@ class _PeripheralDetailPageState extends State { crossAxisAlignment: CrossAxisAlignment.start, children: [ Icon( - Icons.apps, + Icons.settings, size: 16, color: colorScheme.onSurface.withValues(alpha: 0.6), ), @@ -1602,7 +1813,7 @@ class _PeripheralDetailPageState extends State { mainAxisSize: MainAxisSize.min, children: [ Text( - 'Service', + 'Characteristic', style: TextStyle( fontSize: 11, color: colorScheme.onSurface.withValues(alpha: 0.6), @@ -1610,26 +1821,64 @@ class _PeripheralDetailPageState extends State { ), ), const SizedBox(height: 2), - InkWell( - onTap: () { - Clipboard.setData(ClipboardData( - text: selectedService?.uuid ?? "", - )); - _showSnackBar('Copied to clipboard'); - }, - child: Text( - selectedService?.uuid ?? "Unknown", - style: TextStyle( - fontSize: 12, - fontFamily: 'monospace', - color: colorScheme.onSurface, - fontWeight: FontWeight.w500, + Row( + children: [ + Expanded( + child: InkWell( + onTap: () { + Clipboard.setData(ClipboardData( + text: selectedCharacteristic?.uuid ?? "", + )); + _showSnackBar('Copied to clipboard'); + }, + child: Text( + selectedCharacteristic!.uuid, + style: TextStyle( + fontSize: 12, + fontFamily: 'monospace', + color: colorScheme.onSurface, + fontWeight: FontWeight.w500, + ), + ), + ), ), - ), + ], ), ], ), ), + if (canNavigate) ...[ + IconButton( + onPressed: _navigateToPreviousCharacteristic, + icon: const Icon(Icons.arrow_back_ios), + iconSize: 16, + tooltip: 'Previous Characteristic', + padding: EdgeInsets.zero, + constraints: const BoxConstraints( + minWidth: 28, + minHeight: 28, + ), + color: colorScheme.primary, + ), + IconButton( + onPressed: _navigateToNextCharacteristic, + icon: const Icon(Icons.arrow_forward_ios), + iconSize: 16, + tooltip: 'Next Characteristic', + padding: EdgeInsets.zero, + constraints: const BoxConstraints( + minWidth: 28, + minHeight: 28, + ), + color: colorScheme.primary, + ), + const SizedBox(width: 4), + ], + Icon( + Icons.arrow_drop_down, + size: 18, + color: colorScheme.onSurface.withValues(alpha: 0.6), + ), ], ), const SizedBox(height: 8), @@ -1700,6 +1949,14 @@ class _PeripheralDetailPageState extends State { results: _logs, scrollController: _logsScrollController, scrollable: scrollable, + onCopyTap: () async { + if (_logs.isEmpty) return; + final logsText = _logs.join('\n'); + await Clipboard.setData(ClipboardData(text: logsText)); + if (context.mounted) { + _showSnackBar('All logs copied to clipboard'); + } + }, onClearTap: (int? index) { setState(() { if (index != null) { diff --git a/example/lib/peripheral_details/widgets/result_widget.dart b/example/lib/peripheral_details/widgets/result_widget.dart index 401c8af8..067ee139 100644 --- a/example/lib/peripheral_details/widgets/result_widget.dart +++ b/example/lib/peripheral_details/widgets/result_widget.dart @@ -5,9 +5,11 @@ class ResultWidget extends StatelessWidget { final bool scrollable; final ScrollController scrollController; final void Function(int? index) onClearTap; + final void Function()? onCopyTap; const ResultWidget({ required this.results, required this.onClearTap, + this.onCopyTap, this.scrollable = false, required this.scrollController, super.key, @@ -64,6 +66,23 @@ class ResultWidget extends StatelessWidget { color: colorScheme.onSurface, ), ), + if (results.isNotEmpty) ...[ + const SizedBox(width: 8), + IconButton( + icon: Icon( + Icons.copy, + color: colorScheme.primary, + size: 20, + ), + onPressed: onCopyTap, + tooltip: 'Copy all logs', + padding: EdgeInsets.zero, + constraints: const BoxConstraints( + minWidth: 32, + minHeight: 32, + ), + ), + ], const Spacer(), if (results.isNotEmpty) Container( @@ -88,7 +107,7 @@ class ResultWidget extends StatelessWidget { const SizedBox(width: 8), IconButton( icon: Icon( - Icons.clear_all, + Icons.close, color: colorScheme.error, size: 20, ), diff --git a/example/lib/peripheral_details/widgets/services_list_widget.dart b/example/lib/peripheral_details/widgets/services_list_widget.dart index ca816129..3d1dd336 100644 --- a/example/lib/peripheral_details/widgets/services_list_widget.dart +++ b/example/lib/peripheral_details/widgets/services_list_widget.dart @@ -1,9 +1,8 @@ -import 'package:expandable/expandable.dart'; import 'package:flutter/material.dart'; import 'package:universal_ble/universal_ble.dart'; import 'package:universal_ble_example/data/utils.dart'; -class ServicesListWidget extends StatelessWidget { +class ServicesListWidget extends StatefulWidget { final List discoveredServices; final bool scrollable; final void Function(BleService service, BleCharacteristic characteristic)? @@ -13,6 +12,8 @@ class ServicesListWidget extends StatelessWidget { final Set? favoriteServices; final Map? subscribedCharacteristics; final void Function(String serviceUuid)? onFavoriteToggle; + final Set? propertyFilters; + final bool isDiscoveringServices; const ServicesListWidget({ super.key, @@ -24,8 +25,151 @@ class ServicesListWidget extends StatelessWidget { this.favoriteServices, this.subscribedCharacteristics, this.onFavoriteToggle, + this.propertyFilters, + this.isDiscoveringServices = false, }); + @override + State createState() => ServicesListWidgetState(); +} + +class ServicesListWidgetState extends State { + final Map _expandableControllers = {}; + ScrollController? _scrollController; + final Map _characteristicKeys = {}; + + @override + void initState() { + super.initState(); + if (widget.scrollable) { + _scrollController = ScrollController(); + } + _initializeControllers(); + // Scroll to selected characteristic after the first frame + WidgetsBinding.instance.addPostFrameCallback((_) { + _scrollToSelectedCharacteristic(); + }); + } + + @override + void didUpdateWidget(ServicesListWidget oldWidget) { + super.didUpdateWidget(oldWidget); + // Create or dispose scroll controller if scrollable state changed + if (oldWidget.scrollable != widget.scrollable) { + if (widget.scrollable && _scrollController == null) { + _scrollController = ScrollController(); + } else if (!widget.scrollable && _scrollController != null) { + _scrollController!.dispose(); + _scrollController = null; + } + } + // Update controllers if services or selection changed + if (oldWidget.discoveredServices != widget.discoveredServices || + oldWidget.selectedCharacteristic != widget.selectedCharacteristic) { + _initializeControllers(); + WidgetsBinding.instance.addPostFrameCallback((_) { + _scrollToSelectedCharacteristic(); + }); + } + } + + @override + void dispose() { + for (var controller in _expandableControllers.values) { + controller.dispose(); + } + _scrollController?.dispose(); + super.dispose(); + } + + void _initializeControllers() { + // Dispose old controllers + for (var controller in _expandableControllers.values) { + controller.dispose(); + } + _expandableControllers.clear(); + _characteristicKeys.clear(); + + // Create controllers for each service + final sortedServices = _getSortedServices(); + for (var service in sortedServices) { + final controller = ExpansibleController(); + // Expand if this service contains the selected characteristic + if (widget.selectedCharacteristic != null) { + final hasSelectedChar = service.characteristics.any( + (char) => char.uuid == widget.selectedCharacteristic!.uuid, + ); + if (hasSelectedChar) { + controller.expand(); + } + } + _expandableControllers[service.uuid] = controller; + + // Create keys for characteristics + for (var char in service.characteristics) { + _characteristicKeys['${service.uuid}_${char.uuid}'] = GlobalKey(); + } + } + } + + void _scrollToSelectedCharacteristic() { + if (widget.selectedCharacteristic == null) return; + + // Find the key for the selected characteristic + String? selectedKey; + + // Prefer an exact match on both service and characteristic UUIDs + if (widget.selectedService != null) { + final exactKey = + '${widget.selectedService!.uuid}_${widget.selectedCharacteristic!.uuid}'; + if (_characteristicKeys.containsKey(exactKey)) { + selectedKey = exactKey; + } + } + + // Fallback: match by characteristic UUID only if no exact key was found + if (selectedKey == null) { + for (var entry in _characteristicKeys.entries) { + if (entry.key.endsWith('_${widget.selectedCharacteristic!.uuid}')) { + selectedKey = entry.key; + break; + } + } + } + if (selectedKey != null) { + final key = _characteristicKeys[selectedKey]; + if (key?.currentContext != null) { + // Wait a bit for the expansion animation to complete + Future.delayed(const Duration(milliseconds: 100), () { + if (mounted && key?.currentContext != null) { + Scrollable.ensureVisible( + key!.currentContext!, + duration: const Duration(milliseconds: 300), + curve: Curves.easeInOut, + ); + } + }); + } + } + } + + List _getSortedServices() { + return sortBleServices( + widget.discoveredServices, + favoriteServices: widget.favoriteServices, + ); + } + + /// Returns a list of all filtered characteristics with their parent services + List<({BleService service, BleCharacteristic characteristic})> + getFilteredCharacteristics() { + return getFilteredBleCharacteristics( + widget.discoveredServices, + favoriteServices: widget.favoriteServices, + propertyFilters: widget.propertyFilters, + ); + } + @override Widget build(BuildContext context) { final colorScheme = Theme.of(context).colorScheme; @@ -35,29 +179,99 @@ class ServicesListWidget extends StatelessWidget { final selectedCharacteristicBackgroundColor = colorScheme.primaryContainer.withValues(alpha: 0.5); - // Sort services: favorites first, then system services, then others - final sortedServices = List.from(discoveredServices); - sortedServices.sort((a, b) { - final aIsFavorite = favoriteServices?.contains(a.uuid) ?? false; - final bIsFavorite = favoriteServices?.contains(b.uuid) ?? false; - if (aIsFavorite != bIsFavorite) { - return aIsFavorite ? -1 : 1; - } - final aIsSystem = isSystemService(a.uuid); - final bIsSystem = isSystemService(b.uuid); - if (aIsSystem != bIsSystem) { - return aIsSystem ? 1 : -1; - } - return 0; - }); + final sortedServices = _getSortedServices(); + + // Show loading indicator when discovering services and list is empty + if (widget.isDiscoveringServices && sortedServices.isEmpty) { + return Center( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + CircularProgressIndicator( + color: colorScheme.primary, + ), + const SizedBox(height: 16), + Text( + 'Discovering services...', + style: TextStyle( + color: colorScheme.onSurface.withValues(alpha: 0.6), + fontSize: 14, + ), + ), + ], + ), + ); + } + + // Show loading overlay when re-discovering services (list has items) + if (widget.isDiscoveringServices && sortedServices.isNotEmpty) { + return Stack( + children: [ + _buildServicesListView( + sortedServices, + colorScheme, + favoriteStarColor, + subscribedNotificationIconColor, + selectedColor, + selectedCharacteristicBackgroundColor), + Positioned.fill( + child: Container( + color: colorScheme.surface.withValues(alpha: 0.7), + child: Center( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + CircularProgressIndicator( + color: colorScheme.primary, + ), + const SizedBox(height: 16), + Text( + 'Discovering services...', + style: TextStyle( + color: colorScheme.onSurface.withValues(alpha: 0.8), + fontSize: 14, + fontWeight: FontWeight.w500, + ), + ), + ], + ), + ), + ), + ), + ], + ); + } + + // Normal list view when not loading + return _buildServicesListView( + sortedServices, + colorScheme, + favoriteStarColor, + subscribedNotificationIconColor, + selectedColor, + selectedCharacteristicBackgroundColor); + } + + Widget _buildServicesListView( + List sortedServices, + ColorScheme colorScheme, + Color favoriteStarColor, + Color subscribedNotificationIconColor, + Color selectedColor, + Color selectedCharacteristicBackgroundColor, + ) { return ListView.builder( - shrinkWrap: !scrollable, - physics: scrollable ? null : const NeverScrollableScrollPhysics(), + controller: _scrollController, + shrinkWrap: !widget.scrollable, + physics: widget.scrollable ? null : const NeverScrollableScrollPhysics(), itemCount: sortedServices.length, itemBuilder: (BuildContext context, int index) { final service = sortedServices[index]; - final isFavorite = favoriteServices?.contains(service.uuid) ?? false; - final isSelected = selectedService?.uuid == service.uuid; + final isFavorite = + widget.favoriteServices?.contains(service.uuid) ?? false; + final isSelected = widget.selectedService?.uuid == service.uuid; + final controller = + _expandableControllers[service.uuid] ?? ExpansibleController(); return Padding( padding: const EdgeInsets.symmetric(horizontal: 8.0, vertical: 4.0), child: Card( @@ -68,17 +282,21 @@ class ServicesListWidget extends StatelessWidget { ? BorderSide(color: selectedColor, width: 2) : BorderSide.none, ), - child: ExpandablePanel( - header: Padding( - padding: const EdgeInsets.all(12.0), - child: Row( + child: Theme( + data: Theme.of(context).copyWith( + splashFactory: NoSplash.splashFactory, + highlightColor: Colors.transparent, + hoverColor: Colors.transparent, + dividerColor: Colors.transparent, + ), + child: ExpansionTile( + controller: controller, + tilePadding: + const EdgeInsets.symmetric(horizontal: 12.0, vertical: 0), + shape: const Border(), + collapsedShape: const Border(), + title: Row( children: [ - Icon( - Icons.arrow_forward_ios, - size: 14, - color: colorScheme.onSurface.withValues(alpha: 0.6), - ), - const SizedBox(width: 8), Expanded( child: Text( service.uuid, @@ -91,7 +309,7 @@ class ServicesListWidget extends StatelessWidget { ), ), ), - if (onFavoriteToggle != null) ...[ + if (widget.onFavoriteToggle != null) ...[ const SizedBox(width: 8), IconButton( icon: Icon( @@ -100,32 +318,47 @@ class ServicesListWidget extends StatelessWidget { ? favoriteStarColor : colorScheme.onSurface.withValues(alpha: 0.4), ), - onPressed: () => onFavoriteToggle!(service.uuid), + onPressed: () => widget.onFavoriteToggle!(service.uuid), iconSize: 20, padding: EdgeInsets.zero, constraints: const BoxConstraints( minWidth: 32, minHeight: 32, ), + splashColor: Colors.transparent, + highlightColor: Colors.transparent, + hoverColor: Colors.transparent, ), ], ], ), - ), - collapsed: const SizedBox(), - expanded: Padding( - padding: const EdgeInsets.only(bottom: 8.0), - child: Column( - children: service.characteristics.map((e) { - final isCharSelected = - selectedCharacteristic?.uuid == e.uuid; - final isSubscribed = - subscribedCharacteristics?[e.uuid] ?? false; - return Padding( - padding: const EdgeInsets.symmetric( - horizontal: 8.0, - vertical: 4.0, - ), + childrenPadding: const EdgeInsets.only(bottom: 8.0), + children: service.characteristics.where((e) { + // Filter by properties if filters are selected + if (widget.propertyFilters != null && + widget.propertyFilters!.isNotEmpty) { + return e.properties + .any((prop) => widget.propertyFilters!.contains(prop)); + } + return true; + }).map((e) { + final isCharSelected = + widget.selectedCharacteristic?.uuid == e.uuid; + final isSubscribed = + widget.subscribedCharacteristics?[e.uuid] ?? false; + final charKey = + _characteristicKeys['${service.uuid}_${e.uuid}']; + return Padding( + key: charKey, + padding: const EdgeInsets.symmetric( + horizontal: 8.0, + vertical: 4.0, + ), + child: GestureDetector( + onTap: () { + widget.onTap?.call(service, e); + }, + behavior: HitTestBehavior.opaque, child: Container( decoration: BoxDecoration( color: isCharSelected @@ -137,86 +370,79 @@ class ServicesListWidget extends StatelessWidget { ? Border.all(color: selectedColor, width: 1.5) : null, ), - child: InkWell( - onTap: () { - onTap?.call(service, e); - }, - borderRadius: BorderRadius.circular(8), - child: Padding( - padding: const EdgeInsets.all(12.0), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Row( - children: [ + child: Padding( + padding: const EdgeInsets.all(12.0), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Icon( + Icons.arrow_right_outlined, + size: 16, + color: isCharSelected + ? selectedColor + : colorScheme.onSurface + .withValues(alpha: 0.6), + ), + const SizedBox(width: 8), + if (isSubscribed) ...[ Icon( - Icons.arrow_right_outlined, + Icons.notifications_active, + color: subscribedNotificationIconColor, size: 16, - color: isCharSelected - ? selectedColor - : colorScheme.onSurface - .withValues(alpha: 0.6), - ), - const SizedBox(width: 8), - if (isSubscribed) ...[ - Icon( - Icons.notifications_active, - color: subscribedNotificationIconColor, - size: 16, - ), - const SizedBox(width: 4), - ], - Expanded( - child: Text( - e.uuid, - style: TextStyle( - fontSize: 12, - fontFamily: 'monospace', - fontWeight: isCharSelected - ? FontWeight.bold - : FontWeight.w500, - color: isCharSelected - ? selectedColor - : colorScheme.onSurface, - ), - ), ), + const SizedBox(width: 4), ], - ), - const SizedBox(height: 8), - Wrap( - spacing: 6, - runSpacing: 6, - children: e.properties.map((prop) { - return Container( - padding: const EdgeInsets.symmetric( - horizontal: 6, - vertical: 2, - ), - decoration: BoxDecoration( - color: colorScheme.secondaryContainer, - borderRadius: BorderRadius.circular(4), + Expanded( + child: Text( + e.uuid, + style: TextStyle( + fontSize: 12, + fontFamily: 'monospace', + fontWeight: isCharSelected + ? FontWeight.bold + : FontWeight.w500, + color: isCharSelected + ? selectedColor + : colorScheme.onSurface, ), - child: Text( - prop.name, - style: TextStyle( - fontSize: 10, - color: - colorScheme.onSecondaryContainer, - fontWeight: FontWeight.w600, - ), + ), + ), + ], + ), + const SizedBox(height: 8), + Wrap( + spacing: 6, + runSpacing: 6, + children: e.properties.map((prop) { + return Container( + padding: const EdgeInsets.symmetric( + horizontal: 6, + vertical: 2, + ), + decoration: BoxDecoration( + color: colorScheme.secondaryContainer, + borderRadius: BorderRadius.circular(4), + ), + child: Text( + prop.name, + style: TextStyle( + fontSize: 10, + color: colorScheme.onSecondaryContainer, + fontWeight: FontWeight.w600, ), - ); - }).toList(), - ), - ], - ), + ), + ); + }).toList(), + ), + ], ), ), ), - ); - }).toList(), - ), + ), + ); + }).toList(), ), ), ), diff --git a/example/lib/peripheral_details/widgets/services_side_widget.dart b/example/lib/peripheral_details/widgets/services_side_widget.dart index d2c5504c..5df10636 100644 --- a/example/lib/peripheral_details/widgets/services_side_widget.dart +++ b/example/lib/peripheral_details/widgets/services_side_widget.dart @@ -1,20 +1,128 @@ import 'package:flutter/material.dart'; import 'package:universal_ble/universal_ble.dart'; +import 'package:universal_ble_example/data/utils.dart'; +import 'services_list_widget.dart'; -class ServicesSideWidget extends StatelessWidget { +class ServicesSideWidget extends StatefulWidget { final List discoveredServices; - final Function() serviceListBuilder; + final Function(Set? selectedProperties, + GlobalKey? listKey, bool isDiscoveringServices) + serviceListBuilder; final VoidCallback? onCopyServices; + final BleService? selectedService; + final BleCharacteristic? selectedCharacteristic; + final Function(BleService service, BleCharacteristic characteristic)? + onCharacteristicSelected; + final Function(Set? propertyFilters)? + onPropertyFiltersChanged; + final Set? initialPropertyFilters; + final bool isDiscoveringServices; const ServicesSideWidget({ super.key, required this.discoveredServices, required this.serviceListBuilder, this.onCopyServices, + this.selectedService, + this.selectedCharacteristic, + this.onCharacteristicSelected, + this.onPropertyFiltersChanged, + this.initialPropertyFilters, + this.isDiscoveringServices = false, }); + @override + State createState() => _ServicesSideWidgetState(); +} + +class _ServicesSideWidgetState extends State { + late Set? _selectedProperties; + bool _showFilters = false; + final GlobalKey _servicesListKey = GlobalKey(); + + @override + void initState() { + super.initState(); + _selectedProperties = widget.initialPropertyFilters != null + ? Set.from(widget.initialPropertyFilters!) + : null; + } + + @override + void didUpdateWidget(ServicesSideWidget oldWidget) { + super.didUpdateWidget(oldWidget); + // Update filters if initial filters changed from outside + final newFilters = widget.initialPropertyFilters; + final oldFilters = oldWidget.initialPropertyFilters; + if (newFilters != oldFilters) { + setState(() { + _selectedProperties = newFilters != null + ? Set.from(newFilters) + : null; + }); + } + } + + void _togglePropertyFilter(CharacteristicProperty property) { + setState(() { + _selectedProperties ??= {}; + if (_selectedProperties!.contains(property)) { + _selectedProperties!.remove(property); + if (_selectedProperties!.isEmpty) { + _selectedProperties = null; + } + } else { + _selectedProperties!.add(property); + } + }); + widget.onPropertyFiltersChanged?.call(_selectedProperties); + } + + void _clearFilters() { + setState(() { + _selectedProperties = null; + }); + widget.onPropertyFiltersChanged?.call(null); + } + + void _navigateToAdjacent(bool forward) { + final listState = _servicesListKey.currentState; + if (listState == null || widget.selectedCharacteristic == null) return; + + final filtered = listState.getFilteredCharacteristics(); + final result = navigateToAdjacentCharacteristic( + filtered, + widget.selectedCharacteristic!.uuid, + forward, + ); + + if (result != null) { + widget.onCharacteristicSelected?.call( + result.service, + result.characteristic, + ); + } + } + + void _navigateToPrevious() { + _navigateToAdjacent(false); + } + + void _navigateToNext() { + _navigateToAdjacent(true); + } + + bool _canNavigate() { + final listState = _servicesListKey.currentState; + if (listState == null) return false; + final filtered = listState.getFilteredCharacteristics(); + return filtered.length > 1; + } + @override Widget build(BuildContext context) { final colorScheme = Theme.of(context).colorScheme; + final hasActiveFilters = + _selectedProperties != null && _selectedProperties!.isNotEmpty; return Container( decoration: BoxDecoration( color: colorScheme.surfaceContainerHighest, @@ -39,82 +147,245 @@ class ServicesSideWidget extends StatelessWidget { ), ), ), - child: Row( + child: Column( children: [ - Icon( - Icons.apps, - color: colorScheme.primary, - size: 20, - ), - const SizedBox(width: 8), - Text( - 'Services', - style: TextStyle( - fontWeight: FontWeight.bold, - fontSize: 16, - color: colorScheme.onSurface, - ), - ), - const Spacer(), - if (onCopyServices != null) - Padding( - padding: const EdgeInsets.only(right: 8), - child: IconButton( - onPressed: onCopyServices, - icon: const Icon(Icons.copy), + Row( + children: [ + Icon( + Icons.apps, + color: colorScheme.primary, + size: 20, + ), + const SizedBox(width: 8), + Text( + 'Services', + style: TextStyle( + fontWeight: FontWeight.bold, + fontSize: 16, + color: colorScheme.onSurface, + ), + ), + const Spacer(), + // Navigation buttons + if (_canNavigate()) ...[ + IconButton( + onPressed: _navigateToPrevious, + icon: const Icon(Icons.arrow_back_ios), + iconSize: 18, + tooltip: 'Previous Characteristic', + padding: EdgeInsets.zero, + constraints: const BoxConstraints( + minWidth: 32, + minHeight: 32, + ), + color: colorScheme.primary, + ), + IconButton( + onPressed: _navigateToNext, + icon: const Icon(Icons.arrow_forward_ios), + iconSize: 18, + tooltip: 'Next Characteristic', + padding: EdgeInsets.zero, + constraints: const BoxConstraints( + minWidth: 32, + minHeight: 32, + ), + color: colorScheme.primary, + ), + const SizedBox(width: 4), + ], + IconButton( + onPressed: () { + setState(() { + _showFilters = !_showFilters; + }); + }, + icon: Icon( + Icons.filter_list, + color: hasActiveFilters + ? colorScheme.primary + : colorScheme.onSurface, + ), iconSize: 18, - tooltip: 'Copy Services', + tooltip: 'Filter by Properties', padding: EdgeInsets.zero, constraints: const BoxConstraints( minWidth: 32, minHeight: 32, ), - color: colorScheme.onSurface, ), - ), - Container( - padding: const EdgeInsets.symmetric( - horizontal: 8, - vertical: 4, - ), - decoration: BoxDecoration( - color: colorScheme.primaryContainer, - borderRadius: BorderRadius.circular(8), - ), - child: Text( - '${discoveredServices.length}', - style: TextStyle( - color: colorScheme.onPrimaryContainer, - fontSize: 12, - fontWeight: FontWeight.w600, + if (widget.onCopyServices != null) + Padding( + padding: const EdgeInsets.only(right: 8), + child: IconButton( + onPressed: widget.onCopyServices, + icon: const Icon(Icons.copy), + iconSize: 18, + tooltip: 'Copy Services', + padding: EdgeInsets.zero, + constraints: const BoxConstraints( + minWidth: 32, + minHeight: 32, + ), + color: colorScheme.onSurface, + ), + ), + Container( + padding: const EdgeInsets.symmetric( + horizontal: 8, + vertical: 4, + ), + decoration: BoxDecoration( + color: colorScheme.primaryContainer, + borderRadius: BorderRadius.circular(8), + ), + child: Text( + '${widget.discoveredServices.length}', + style: TextStyle( + color: colorScheme.onPrimaryContainer, + fontSize: 12, + fontWeight: FontWeight.w600, + ), + ), ), - ), + ], ), - ], - ), - ), - Expanded( - child: discoveredServices.isEmpty - ? Center( + if (_showFilters) ...[ + const SizedBox(height: 12), + Container( + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + color: colorScheme.surfaceContainerHighest, + borderRadius: BorderRadius.circular(8), + border: Border.all( + color: colorScheme.outline.withValues(alpha: 0.2), + ), + ), child: Column( - mainAxisAlignment: MainAxisAlignment.center, + crossAxisAlignment: CrossAxisAlignment.start, children: [ - Icon( - Icons.apps_outlined, - size: 48, - color: colorScheme.onSurface.withValues(alpha: 0.3), + Row( + children: [ + Text( + 'Filter by Properties', + style: TextStyle( + fontSize: 12, + fontWeight: FontWeight.w600, + color: colorScheme.onSurface, + ), + ), + const Spacer(), + if (hasActiveFilters) + TextButton( + onPressed: _clearFilters, + style: TextButton.styleFrom( + padding: const EdgeInsets.symmetric( + horizontal: 8, + vertical: 4, + ), + minimumSize: Size.zero, + tapTargetSize: + MaterialTapTargetSize.shrinkWrap, + ), + child: Text( + 'Clear', + style: TextStyle( + fontSize: 11, + color: colorScheme.primary, + ), + ), + ), + ], ), - const SizedBox(height: 16), - Text( - 'No Services Discovered', - style: TextStyle( - color: colorScheme.onSurface.withValues(alpha: 0.6), - ), + const SizedBox(height: 8), + Wrap( + spacing: 6, + runSpacing: 6, + children: + CharacteristicProperty.values.map((property) { + final isSelected = + _selectedProperties?.contains(property) ?? + false; + return FilterChip( + label: Text( + property.name, + style: TextStyle( + fontSize: 11, + fontWeight: isSelected + ? FontWeight.w600 + : FontWeight.normal, + ), + ), + selected: isSelected, + onSelected: (_) => + _togglePropertyFilter(property), + selectedColor: colorScheme.primaryContainer, + checkmarkColor: colorScheme.onPrimaryContainer, + labelStyle: TextStyle( + color: isSelected + ? colorScheme.onPrimaryContainer + : colorScheme.onSurface, + ), + side: BorderSide( + color: isSelected + ? colorScheme.primary + : colorScheme.outline + .withValues(alpha: 0.3), + width: isSelected ? 1.5 : 1, + ), + ); + }).toList(), ), ], ), + ), + ], + ], + ), + ), + Expanded( + child: widget.discoveredServices.isEmpty + ? Center( + child: widget.isDiscoveringServices + ? Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + CircularProgressIndicator( + color: colorScheme.primary, + ), + const SizedBox(height: 16), + Text( + 'Discovering services...', + style: TextStyle( + color: colorScheme.onSurface + .withValues(alpha: 0.6), + fontSize: 14, + ), + ), + ], + ) + : Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Icon( + Icons.apps_outlined, + size: 48, + color: colorScheme.onSurface + .withValues(alpha: 0.3), + ), + const SizedBox(height: 16), + Text( + 'No Services Discovered', + style: TextStyle( + color: colorScheme.onSurface + .withValues(alpha: 0.6), + ), + ), + ], + ), ) - : serviceListBuilder(), + : widget.serviceListBuilder( + _selectedProperties, _servicesListKey, widget.isDiscoveringServices), ), ], ), diff --git a/example/macos/Podfile.lock b/example/macos/Podfile.lock index 70d92e8b..ec214e6c 100644 --- a/example/macos/Podfile.lock +++ b/example/macos/Podfile.lock @@ -38,11 +38,11 @@ EXTERNAL SOURCES: SPEC CHECKSUMS: FlutterMacOS: d0db08ddef1a9af05a5ec4b724367152bb0500b1 - package_info_plus: 12f1c5c2cfe8727ca46cbd0b26677728972d9a5b - path_provider_foundation: 0b743cbb62d8e47eab856f09262bb8c1ddcfe6ba - shared_preferences_foundation: 5086985c1d43c5ba4d5e69a4e8083a389e2909e6 - universal_ble: 65e1257dffc557cc7991a93d253beeddc7c1dc92 - url_launcher_macos: 175a54c831f4375a6cf895875f716ee5af3888ce + package_info_plus: f0052d280d17aa382b932f399edf32507174e870 + path_provider_foundation: bb55f6dbba17d0dccd6737fe6f7f34fbd0376880 + shared_preferences_foundation: 7036424c3d8ec98dfe75ff1667cb0cd531ec82bb + universal_ble: 45519b2aeafe62761e2c6309f8927edb5288b914 + url_launcher_macos: f87a979182d112f911de6820aefddaf56ee9fbfd PODFILE CHECKSUM: 9ebaf0ce3d369aaa26a9ea0e159195ed94724cf3 diff --git a/example/pubspec.lock b/example/pubspec.lock index 2144c364..203f3771 100644 --- a/example/pubspec.lock +++ b/example/pubspec.lock @@ -145,14 +145,6 @@ packages: url: "https://pub.dev" source: hosted version: "1.0.0" - expandable: - dependency: "direct main" - description: - name: expandable - sha256: "9604d612d4d1146dafa96c6d8eec9c2ff0994658d6d09fed720ab788c7f5afc2" - url: "https://pub.dev" - source: hosted - version: "5.0.1" fake_async: dependency: transitive description: diff --git a/example/pubspec.yaml b/example/pubspec.yaml index a334ff13..87f97bab 100644 --- a/example/pubspec.yaml +++ b/example/pubspec.yaml @@ -10,7 +10,6 @@ dependencies: flutter: sdk: flutter convert: ^3.1.1 - expandable: ^5.0.1 cupertino_icons: ^1.0.2 shared_preferences: ^2.5.4 package_info_plus: ^9.0.0