Improve example app (#210)
* Improve example app - Introduced utility functions for sorting BLE services and filtering characteristics based on properties. - Updated the peripheral detail page to support reading all characteristics and improved value formatting for better readability. - Added navigation functionality for adjacent characteristics and integrated property filters in the services list widget. - Enhanced the services side widget to manage selected properties and provide a clearer UI for service interactions. - Implemented copy functionality for logs and improved overall user experience in the peripheral details section. * Remove expandable * Automatically start scanning on launch * Show loading indicator for discovering services * Decrease the height of TextFormField
This commit is contained in:
+28
-16
@@ -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
|
||||
@@ -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<BleService> sortBleServices(
|
||||
List<BleService> services, {
|
||||
Set<String>? favoriteServices,
|
||||
}) {
|
||||
final sortedServices = List<BleService>.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<BleService> services, {
|
||||
Set<String>? favoriteServices,
|
||||
Set<CharacteristicProperty>? 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];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -51,7 +51,17 @@ class _ScannerScreenState extends State<ScannerScreen> {
|
||||
(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<ScannerScreen> {
|
||||
bool get _isBluetoothAvailable =>
|
||||
bleAvailabilityState == AvailabilityState.poweredOn;
|
||||
|
||||
Future<void> _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<ScannerScreen> {
|
||||
triggerMode: TooltipTriggerMode.tap,
|
||||
child: BleAvailabilityIcon(onAvailabilityStateChanged: (state) {
|
||||
setState(() => bleAvailabilityState = state);
|
||||
// Auto-start scanning when Bluetooth becomes available
|
||||
if (state == AvailabilityState.poweredOn) {
|
||||
_tryAutoStartScan();
|
||||
}
|
||||
}),
|
||||
),
|
||||
],
|
||||
|
||||
@@ -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<PeripheralDetailPage> {
|
||||
final List<String> _logs = [];
|
||||
final binaryCode = TextEditingController();
|
||||
bool _isLoading = false;
|
||||
bool _isDiscoveringServices = false;
|
||||
bool _isDeviceInfoExpanded = false;
|
||||
bool _isDeviceActionsExpanded = true;
|
||||
final Map<String, bool> _subscribedCharacteristics = {};
|
||||
@@ -41,6 +43,7 @@ class _PeripheralDetailPageState extends State<PeripheralDetailPage> {
|
||||
BleCharacteristic? selectedCharacteristic;
|
||||
final ScrollController _logsScrollController = ScrollController();
|
||||
final Set<String> _favoriteServices = {};
|
||||
Set<CharacteristicProperty>? _currentPropertyFilters;
|
||||
|
||||
void _loadFavoriteServices() {
|
||||
final favorites = StorageService.instance.getFavoriteServices();
|
||||
@@ -105,12 +108,15 @@ class _PeripheralDetailPageState extends State<PeripheralDetailPage> {
|
||||
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<PeripheralDetailPage> {
|
||||
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<PeripheralDetailPage> {
|
||||
_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<void> _readValue() async {
|
||||
@@ -159,8 +204,7 @@ class _PeripheralDetailPageState extends State<PeripheralDetailPage> {
|
||||
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<PeripheralDetailPage> {
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _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<PeripheralDetailPage> {
|
||||
padding: EdgeInsets.only(bottom: 10.0),
|
||||
child: ServicesSideWidget(
|
||||
discoveredServices: discoveredServices,
|
||||
serviceListBuilder: () => _buildServicesList(onSelect: (_, __) {
|
||||
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<PeripheralDetailPage> {
|
||||
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<PeripheralDetailPage> {
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(16.0),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
spacing: 12,
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
@@ -560,13 +692,64 @@ class _PeripheralDetailPageState extends State<PeripheralDetailPage> {
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
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),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
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),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
Form(
|
||||
key: valueFormKey,
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
spacing: 12,
|
||||
children: [
|
||||
TextFormField(
|
||||
Row(
|
||||
spacing: 12,
|
||||
children: [
|
||||
Expanded(
|
||||
flex: 3,
|
||||
child: TextFormField(
|
||||
controller: binaryCode,
|
||||
enabled: isConnected &&
|
||||
_hasSelectedCharacteristicProperty([
|
||||
@@ -592,16 +775,20 @@ class _PeripheralDetailPageState extends State<PeripheralDetailPage> {
|
||||
),
|
||||
filled: true,
|
||||
fillColor: colorScheme.surfaceContainerHighest,
|
||||
isDense: true,
|
||||
contentPadding: const EdgeInsets.symmetric(
|
||||
horizontal: 12,
|
||||
vertical: 12,
|
||||
),
|
||||
prefixIcon: Icon(
|
||||
Icons.code,
|
||||
color: colorScheme.primary,
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
Row(
|
||||
children: [
|
||||
),
|
||||
Expanded(
|
||||
flex: 1,
|
||||
child: ElevatedButton.icon(
|
||||
onPressed: isConnected &&
|
||||
_hasSelectedCharacteristicProperty([
|
||||
@@ -625,35 +812,11 @@ class _PeripheralDetailPageState extends State<PeripheralDetailPage> {
|
||||
),
|
||||
),
|
||||
),
|
||||
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<PeripheralDetailPage> {
|
||||
);
|
||||
},
|
||||
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<PeripheralDetailPage> {
|
||||
}
|
||||
: 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<PeripheralDetailPage> {
|
||||
);
|
||||
},
|
||||
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,6 +1499,7 @@ class _PeripheralDetailPageState extends State<PeripheralDetailPage> {
|
||||
),
|
||||
),
|
||||
),
|
||||
if (BleCapabilities.hasSystemPairingApi)
|
||||
OutlinedButton.icon(
|
||||
onPressed: () async {
|
||||
await _executeWithLoading(
|
||||
@@ -1482,14 +1646,20 @@ class _PeripheralDetailPageState extends State<PeripheralDetailPage> {
|
||||
|
||||
Widget _buildServicesList({
|
||||
Function(BleService, BleCharacteristic?)? onSelect,
|
||||
Set<CharacteristicProperty>? propertyFilters,
|
||||
GlobalKey<ServicesListWidgetState>? 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<PeripheralDetailPage> {
|
||||
);
|
||||
}
|
||||
|
||||
/// 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(
|
||||
@@ -1530,6 +1752,52 @@ class _PeripheralDetailPageState extends State<PeripheralDetailPage> {
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Icon(
|
||||
Icons.apps,
|
||||
size: 16,
|
||||
color: colorScheme.onSurface.withValues(alpha: 0.6),
|
||||
),
|
||||
const SizedBox(width: 6),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Text(
|
||||
'Service',
|
||||
style: TextStyle(
|
||||
fontSize: 11,
|
||||
color: colorScheme.onSurface.withValues(alpha: 0.6),
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
),
|
||||
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,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
@@ -1579,6 +1847,33 @@ class _PeripheralDetailPageState extends State<PeripheralDetailPage> {
|
||||
],
|
||||
),
|
||||
),
|
||||
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,
|
||||
@@ -1587,52 +1882,6 @@ class _PeripheralDetailPageState extends State<PeripheralDetailPage> {
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Icon(
|
||||
Icons.apps,
|
||||
size: 16,
|
||||
color: colorScheme.onSurface.withValues(alpha: 0.6),
|
||||
),
|
||||
const SizedBox(width: 6),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Text(
|
||||
'Service',
|
||||
style: TextStyle(
|
||||
fontSize: 11,
|
||||
color: colorScheme.onSurface.withValues(alpha: 0.6),
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
),
|
||||
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,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
@@ -1700,6 +1949,14 @@ class _PeripheralDetailPageState extends State<PeripheralDetailPage> {
|
||||
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) {
|
||||
|
||||
@@ -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,
|
||||
),
|
||||
|
||||
@@ -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<BleService> discoveredServices;
|
||||
final bool scrollable;
|
||||
final void Function(BleService service, BleCharacteristic characteristic)?
|
||||
@@ -13,6 +12,8 @@ class ServicesListWidget extends StatelessWidget {
|
||||
final Set<String>? favoriteServices;
|
||||
final Map<String, bool>? subscribedCharacteristics;
|
||||
final void Function(String serviceUuid)? onFavoriteToggle;
|
||||
final Set<CharacteristicProperty>? 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<ServicesListWidget> createState() => ServicesListWidgetState();
|
||||
}
|
||||
|
||||
class ServicesListWidgetState extends State<ServicesListWidget> {
|
||||
final Map<String, ExpansibleController> _expandableControllers = {};
|
||||
ScrollController? _scrollController;
|
||||
final Map<String, GlobalKey> _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<BleService> _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<BleService>.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 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,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
final aIsSystem = isSystemService(a.uuid);
|
||||
final bIsSystem = isSystemService(b.uuid);
|
||||
if (aIsSystem != bIsSystem) {
|
||||
return aIsSystem ? 1 : -1;
|
||||
|
||||
// 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,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
return 0;
|
||||
});
|
||||
|
||||
// Normal list view when not loading
|
||||
return _buildServicesListView(
|
||||
sortedServices,
|
||||
colorScheme,
|
||||
favoriteStarColor,
|
||||
subscribedNotificationIconColor,
|
||||
selectedColor,
|
||||
selectedCharacteristicBackgroundColor);
|
||||
}
|
||||
|
||||
Widget _buildServicesListView(
|
||||
List<BleService> 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(
|
||||
children: [
|
||||
Icon(
|
||||
Icons.arrow_forward_ios,
|
||||
size: 14,
|
||||
color: colorScheme.onSurface.withValues(alpha: 0.6),
|
||||
child: Theme(
|
||||
data: Theme.of(context).copyWith(
|
||||
splashFactory: NoSplash.splashFactory,
|
||||
highlightColor: Colors.transparent,
|
||||
hoverColor: Colors.transparent,
|
||||
dividerColor: Colors.transparent,
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
child: ExpansionTile(
|
||||
controller: controller,
|
||||
tilePadding:
|
||||
const EdgeInsets.symmetric(horizontal: 12.0, vertical: 0),
|
||||
shape: const Border(),
|
||||
collapsedShape: const Border(),
|
||||
title: Row(
|
||||
children: [
|
||||
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) {
|
||||
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 =
|
||||
selectedCharacteristic?.uuid == e.uuid;
|
||||
widget.selectedCharacteristic?.uuid == e.uuid;
|
||||
final isSubscribed =
|
||||
subscribedCharacteristics?[e.uuid] ?? false;
|
||||
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,11 +370,6 @@ 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(
|
||||
@@ -201,8 +429,7 @@ class ServicesListWidget extends StatelessWidget {
|
||||
prop.name,
|
||||
style: TextStyle(
|
||||
fontSize: 10,
|
||||
color:
|
||||
colorScheme.onSecondaryContainer,
|
||||
color: colorScheme.onSecondaryContainer,
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
@@ -219,7 +446,6 @@ class ServicesListWidget extends StatelessWidget {
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
@@ -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<BleService> discoveredServices;
|
||||
final Function() serviceListBuilder;
|
||||
final Function(Set<CharacteristicProperty>? selectedProperties,
|
||||
GlobalKey<ServicesListWidgetState>? listKey, bool isDiscoveringServices)
|
||||
serviceListBuilder;
|
||||
final VoidCallback? onCopyServices;
|
||||
final BleService? selectedService;
|
||||
final BleCharacteristic? selectedCharacteristic;
|
||||
final Function(BleService service, BleCharacteristic characteristic)?
|
||||
onCharacteristicSelected;
|
||||
final Function(Set<CharacteristicProperty>? propertyFilters)?
|
||||
onPropertyFiltersChanged;
|
||||
final Set<CharacteristicProperty>? 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<ServicesSideWidget> createState() => _ServicesSideWidgetState();
|
||||
}
|
||||
|
||||
class _ServicesSideWidgetState extends State<ServicesSideWidget> {
|
||||
late Set<CharacteristicProperty>? _selectedProperties;
|
||||
bool _showFilters = false;
|
||||
final GlobalKey<ServicesListWidgetState> _servicesListKey = GlobalKey();
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_selectedProperties = widget.initialPropertyFilters != null
|
||||
? Set<CharacteristicProperty>.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<CharacteristicProperty>.from(newFilters)
|
||||
: null;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
void _togglePropertyFilter(CharacteristicProperty property) {
|
||||
setState(() {
|
||||
_selectedProperties ??= <CharacteristicProperty>{};
|
||||
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,7 +147,9 @@ class ServicesSideWidget extends StatelessWidget {
|
||||
),
|
||||
),
|
||||
),
|
||||
child: Row(
|
||||
child: Column(
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
Icon(
|
||||
Icons.apps,
|
||||
@@ -56,11 +166,59 @@ class ServicesSideWidget extends StatelessWidget {
|
||||
),
|
||||
),
|
||||
const Spacer(),
|
||||
if (onCopyServices != null)
|
||||
// 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: 'Filter by Properties',
|
||||
padding: EdgeInsets.zero,
|
||||
constraints: const BoxConstraints(
|
||||
minWidth: 32,
|
||||
minHeight: 32,
|
||||
),
|
||||
),
|
||||
if (widget.onCopyServices != null)
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(right: 8),
|
||||
child: IconButton(
|
||||
onPressed: onCopyServices,
|
||||
onPressed: widget.onCopyServices,
|
||||
icon: const Icon(Icons.copy),
|
||||
iconSize: 18,
|
||||
tooltip: 'Copy Services',
|
||||
@@ -82,7 +240,7 @@ class ServicesSideWidget extends StatelessWidget {
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
child: Text(
|
||||
'${discoveredServices.length}',
|
||||
'${widget.discoveredServices.length}',
|
||||
style: TextStyle(
|
||||
color: colorScheme.onPrimaryContainer,
|
||||
fontSize: 12,
|
||||
@@ -92,29 +250,142 @@ class ServicesSideWidget extends StatelessWidget {
|
||||
),
|
||||
],
|
||||
),
|
||||
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(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
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: 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: discoveredServices.isEmpty
|
||||
child: widget.discoveredServices.isEmpty
|
||||
? Center(
|
||||
child: Column(
|
||||
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),
|
||||
color: colorScheme.onSurface
|
||||
.withValues(alpha: 0.3),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
Text(
|
||||
'No Services Discovered',
|
||||
style: TextStyle(
|
||||
color: colorScheme.onSurface.withValues(alpha: 0.6),
|
||||
color: colorScheme.onSurface
|
||||
.withValues(alpha: 0.6),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
)
|
||||
: serviceListBuilder(),
|
||||
: widget.serviceListBuilder(
|
||||
_selectedProperties, _servicesListKey, widget.isDiscoveringServices),
|
||||
),
|
||||
],
|
||||
),
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user