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
|
## 1.1.0
|
||||||
* Add support for `autoConnect` parameter
|
* **Services & Characteristics:**
|
||||||
* Display RSSI values in device details
|
* Add property filtering for characteristics with visual filter chips
|
||||||
* Persist filters
|
* Add navigation buttons to navigate between characteristics (previous/next)
|
||||||
* Fix clear log button
|
* Improve service sorting (favorites first, system services last)
|
||||||
* Move "Copy Services" button to Services panel header
|
* Enhance services list UI with better filtering and navigation
|
||||||
* Enhance services format to be more detailed and human-readable
|
* 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
|
## 1.0.0
|
||||||
* Initial release
|
* Initial release
|
||||||
@@ -1,3 +1,5 @@
|
|||||||
|
import 'package:universal_ble/universal_ble.dart';
|
||||||
|
|
||||||
bool isSystemService(String uuid) {
|
bool isSystemService(String uuid) {
|
||||||
final normalized = uuid.toUpperCase().replaceAll('-', '');
|
final normalized = uuid.toUpperCase().replaceAll('-', '');
|
||||||
return normalized == '00001800' ||
|
return normalized == '00001800' ||
|
||||||
@@ -5,3 +7,98 @@ bool isSystemService(String uuid) {
|
|||||||
normalized == '0000180A' ||
|
normalized == '0000180A' ||
|
||||||
normalized.startsWith('000018');
|
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),
|
(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
|
// Load company identifiers in the background
|
||||||
CompanyIdentifierService.instance.load();
|
CompanyIdentifierService.instance.load();
|
||||||
@@ -305,6 +315,17 @@ class _ScannerScreenState extends State<ScannerScreen> {
|
|||||||
bool get _isBluetoothAvailable =>
|
bool get _isBluetoothAvailable =>
|
||||||
bleAvailabilityState == AvailabilityState.poweredOn;
|
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() {
|
String _getBluetoothAvailabilityTooltip() {
|
||||||
switch (bleAvailabilityState) {
|
switch (bleAvailabilityState) {
|
||||||
case AvailabilityState.poweredOn:
|
case AvailabilityState.poweredOn:
|
||||||
@@ -410,6 +431,10 @@ class _ScannerScreenState extends State<ScannerScreen> {
|
|||||||
triggerMode: TooltipTriggerMode.tap,
|
triggerMode: TooltipTriggerMode.tap,
|
||||||
child: BleAvailabilityIcon(onAvailabilityStateChanged: (state) {
|
child: BleAvailabilityIcon(onAvailabilityStateChanged: (state) {
|
||||||
setState(() => bleAvailabilityState = 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:flutter/services.dart';
|
||||||
import 'package:universal_ble/universal_ble.dart';
|
import 'package:universal_ble/universal_ble.dart';
|
||||||
import 'package:universal_ble_example/data/storage_service.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/result_widget.dart';
|
||||||
import 'package:universal_ble_example/peripheral_details/widgets/services_list_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';
|
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 List<String> _logs = [];
|
||||||
final binaryCode = TextEditingController();
|
final binaryCode = TextEditingController();
|
||||||
bool _isLoading = false;
|
bool _isLoading = false;
|
||||||
|
bool _isDiscoveringServices = false;
|
||||||
bool _isDeviceInfoExpanded = false;
|
bool _isDeviceInfoExpanded = false;
|
||||||
bool _isDeviceActionsExpanded = true;
|
bool _isDeviceActionsExpanded = true;
|
||||||
final Map<String, bool> _subscribedCharacteristics = {};
|
final Map<String, bool> _subscribedCharacteristics = {};
|
||||||
@@ -41,6 +43,7 @@ class _PeripheralDetailPageState extends State<PeripheralDetailPage> {
|
|||||||
BleCharacteristic? selectedCharacteristic;
|
BleCharacteristic? selectedCharacteristic;
|
||||||
final ScrollController _logsScrollController = ScrollController();
|
final ScrollController _logsScrollController = ScrollController();
|
||||||
final Set<String> _favoriteServices = {};
|
final Set<String> _favoriteServices = {};
|
||||||
|
Set<CharacteristicProperty>? _currentPropertyFilters;
|
||||||
|
|
||||||
void _loadFavoriteServices() {
|
void _loadFavoriteServices() {
|
||||||
final favorites = StorageService.instance.getFavoriteServices();
|
final favorites = StorageService.instance.getFavoriteServices();
|
||||||
@@ -105,12 +108,15 @@ class _PeripheralDetailPageState extends State<PeripheralDetailPage> {
|
|||||||
Uint8List value,
|
Uint8List value,
|
||||||
int? timestamp,
|
int? timestamp,
|
||||||
) {
|
) {
|
||||||
String s = String.fromCharCodes(value);
|
String data = _formatReadValue(value);
|
||||||
String data = '$s\nraw : ${value.toString()}';
|
|
||||||
DateTime? timestampDateTime = timestamp != null
|
DateTime? timestampDateTime = timestamp != null
|
||||||
? DateTime.fromMillisecondsSinceEpoch(timestamp)
|
? DateTime.fromMillisecondsSinceEpoch(timestamp)
|
||||||
: null;
|
: 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);
|
_addLog("Value", data);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -123,6 +129,10 @@ class _PeripheralDetailPageState extends State<PeripheralDetailPage> {
|
|||||||
const webWarning =
|
const webWarning =
|
||||||
"Note: Only services added in ScanFilter or WebOptions will be discovered";
|
"Note: Only services added in ScanFilter or WebOptions will be discovered";
|
||||||
|
|
||||||
|
setState(() {
|
||||||
|
_isDiscoveringServices = true;
|
||||||
|
});
|
||||||
|
|
||||||
await _executeWithLoading(
|
await _executeWithLoading(
|
||||||
() async {
|
() async {
|
||||||
var services = await bleDevice.discoverServices(withDescriptors: false);
|
var services = await bleDevice.discoverServices(withDescriptors: false);
|
||||||
@@ -151,6 +161,41 @@ class _PeripheralDetailPageState extends State<PeripheralDetailPage> {
|
|||||||
_addLog("DiscoverServicesError", errorMessage.toString());
|
_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 {
|
Future<void> _readValue() async {
|
||||||
@@ -159,8 +204,7 @@ class _PeripheralDetailPageState extends State<PeripheralDetailPage> {
|
|||||||
await _executeWithLoading(
|
await _executeWithLoading(
|
||||||
() async {
|
() async {
|
||||||
Uint8List value = await selectedCharacteristic.read();
|
Uint8List value = await selectedCharacteristic.read();
|
||||||
String s = String.fromCharCodes(value);
|
String data = _formatReadValue(value);
|
||||||
String data = '$s\nraw : ${value.toString()}';
|
|
||||||
_addLog('Read', data);
|
_addLog('Read', data);
|
||||||
},
|
},
|
||||||
onError: (error) {
|
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(
|
CharacteristicSubscription? _getCharacteristicSubscription(
|
||||||
BleCharacteristic characteristic,
|
BleCharacteristic characteristic,
|
||||||
) {
|
) {
|
||||||
@@ -320,9 +404,35 @@ class _PeripheralDetailPageState extends State<PeripheralDetailPage> {
|
|||||||
padding: EdgeInsets.only(bottom: 10.0),
|
padding: EdgeInsets.only(bottom: 10.0),
|
||||||
child: ServicesSideWidget(
|
child: ServicesSideWidget(
|
||||||
discoveredServices: discoveredServices,
|
discoveredServices: discoveredServices,
|
||||||
serviceListBuilder: () => _buildServicesList(onSelect: (_, __) {
|
selectedService: selectedService,
|
||||||
Navigator.pop(context);
|
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
|
onCopyServices: discoveredServices.isNotEmpty
|
||||||
? () async {
|
? () async {
|
||||||
await _copyServicesToClipboard();
|
await _copyServicesToClipboard();
|
||||||
@@ -472,7 +582,28 @@ class _PeripheralDetailPageState extends State<PeripheralDetailPage> {
|
|||||||
flex: 1,
|
flex: 1,
|
||||||
child: ServicesSideWidget(
|
child: ServicesSideWidget(
|
||||||
discoveredServices: discoveredServices,
|
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
|
onCopyServices: discoveredServices.isNotEmpty
|
||||||
? _copyServicesToClipboard
|
? _copyServicesToClipboard
|
||||||
: null,
|
: null,
|
||||||
@@ -540,7 +671,8 @@ class _PeripheralDetailPageState extends State<PeripheralDetailPage> {
|
|||||||
child: Padding(
|
child: Padding(
|
||||||
padding: const EdgeInsets.all(16.0),
|
padding: const EdgeInsets.all(16.0),
|
||||||
child: Column(
|
child: Column(
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
spacing: 12,
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||||
children: [
|
children: [
|
||||||
Row(
|
Row(
|
||||||
children: [
|
children: [
|
||||||
@@ -560,48 +692,103 @@ 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(
|
Form(
|
||||||
key: valueFormKey,
|
key: valueFormKey,
|
||||||
child: Column(
|
child: Column(
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
spacing: 12,
|
||||||
children: [
|
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(
|
|
||||||
borderRadius: BorderRadius.circular(12),
|
|
||||||
),
|
|
||||||
filled: true,
|
|
||||||
fillColor: colorScheme.surfaceContainerHighest,
|
|
||||||
prefixIcon: Icon(
|
|
||||||
Icons.code,
|
|
||||||
color: colorScheme.primary,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
const SizedBox(height: 12),
|
|
||||||
Row(
|
Row(
|
||||||
|
spacing: 12,
|
||||||
children: [
|
children: [
|
||||||
Expanded(
|
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(
|
child: ElevatedButton.icon(
|
||||||
onPressed: isConnected &&
|
onPressed: isConnected &&
|
||||||
_hasSelectedCharacteristicProperty([
|
_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(
|
Wrap(
|
||||||
spacing: 12,
|
spacing: 12,
|
||||||
runSpacing: 12,
|
runSpacing: 12,
|
||||||
@@ -1218,7 +1381,7 @@ class _PeripheralDetailPageState extends State<PeripheralDetailPage> {
|
|||||||
);
|
);
|
||||||
},
|
},
|
||||||
icon: const Icon(Icons.info_outline),
|
icon: const Icon(Icons.info_outline),
|
||||||
label: const Text('State'),
|
label: const Text('Connection State'),
|
||||||
style: OutlinedButton.styleFrom(
|
style: OutlinedButton.styleFrom(
|
||||||
foregroundColor: colorScheme.onSurface,
|
foregroundColor: colorScheme.onSurface,
|
||||||
padding: const EdgeInsets.symmetric(
|
padding: const EdgeInsets.symmetric(
|
||||||
@@ -1242,7 +1405,7 @@ class _PeripheralDetailPageState extends State<PeripheralDetailPage> {
|
|||||||
}
|
}
|
||||||
: null,
|
: null,
|
||||||
icon: const Icon(Icons.signal_cellular_alt),
|
icon: const Icon(Icons.signal_cellular_alt),
|
||||||
label: const Text('Get RSSI'),
|
label: const Text('RSSI'),
|
||||||
style: OutlinedButton.styleFrom(
|
style: OutlinedButton.styleFrom(
|
||||||
foregroundColor: colorScheme.onSurface,
|
foregroundColor: colorScheme.onSurface,
|
||||||
padding: const EdgeInsets.symmetric(
|
padding: const EdgeInsets.symmetric(
|
||||||
@@ -1324,7 +1487,7 @@ class _PeripheralDetailPageState extends State<PeripheralDetailPage> {
|
|||||||
);
|
);
|
||||||
},
|
},
|
||||||
icon: const Icon(Icons.check_circle),
|
icon: const Icon(Icons.check_circle),
|
||||||
label: const Text('Check Paired'),
|
label: const Text('Pairing State'),
|
||||||
style: OutlinedButton.styleFrom(
|
style: OutlinedButton.styleFrom(
|
||||||
foregroundColor: colorScheme.onSurface,
|
foregroundColor: colorScheme.onSurface,
|
||||||
padding: const EdgeInsets.symmetric(
|
padding: const EdgeInsets.symmetric(
|
||||||
@@ -1336,33 +1499,34 @@ class _PeripheralDetailPageState extends State<PeripheralDetailPage> {
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
OutlinedButton.icon(
|
if (BleCapabilities.hasSystemPairingApi)
|
||||||
onPressed: () async {
|
OutlinedButton.icon(
|
||||||
await _executeWithLoading(
|
onPressed: () async {
|
||||||
() async {
|
await _executeWithLoading(
|
||||||
await bleDevice.unpair();
|
() async {
|
||||||
},
|
await bleDevice.unpair();
|
||||||
onError: (error) {
|
},
|
||||||
_addLog('UnpairError', error);
|
onError: (error) {
|
||||||
},
|
_addLog('UnpairError', error);
|
||||||
);
|
},
|
||||||
},
|
);
|
||||||
icon: const Icon(Icons.link_off),
|
},
|
||||||
label: const Text('Unpair'),
|
icon: const Icon(Icons.link_off),
|
||||||
style: OutlinedButton.styleFrom(
|
label: const Text('Unpair'),
|
||||||
foregroundColor: colorScheme.error,
|
style: OutlinedButton.styleFrom(
|
||||||
side: BorderSide(
|
foregroundColor: colorScheme.error,
|
||||||
color: colorScheme.error,
|
side: BorderSide(
|
||||||
),
|
color: colorScheme.error,
|
||||||
padding: const EdgeInsets.symmetric(
|
),
|
||||||
horizontal: 16,
|
padding: const EdgeInsets.symmetric(
|
||||||
vertical: 12,
|
horizontal: 16,
|
||||||
),
|
vertical: 12,
|
||||||
shape: RoundedRectangleBorder(
|
),
|
||||||
borderRadius: BorderRadius.circular(8),
|
shape: RoundedRectangleBorder(
|
||||||
|
borderRadius: BorderRadius.circular(8),
|
||||||
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
@@ -1482,14 +1646,20 @@ class _PeripheralDetailPageState extends State<PeripheralDetailPage> {
|
|||||||
|
|
||||||
Widget _buildServicesList({
|
Widget _buildServicesList({
|
||||||
Function(BleService, BleCharacteristic?)? onSelect,
|
Function(BleService, BleCharacteristic?)? onSelect,
|
||||||
|
Set<CharacteristicProperty>? propertyFilters,
|
||||||
|
GlobalKey<ServicesListWidgetState>? listKey,
|
||||||
|
bool isDiscoveringServices = false,
|
||||||
}) {
|
}) {
|
||||||
return ServicesListWidget(
|
return ServicesListWidget(
|
||||||
|
key: listKey,
|
||||||
discoveredServices: discoveredServices,
|
discoveredServices: discoveredServices,
|
||||||
selectedService: selectedService,
|
selectedService: selectedService,
|
||||||
selectedCharacteristic: selectedCharacteristic,
|
selectedCharacteristic: selectedCharacteristic,
|
||||||
favoriteServices: _favoriteServices,
|
favoriteServices: _favoriteServices,
|
||||||
subscribedCharacteristics: _subscribedCharacteristics,
|
subscribedCharacteristics: _subscribedCharacteristics,
|
||||||
scrollable: true,
|
scrollable: true,
|
||||||
|
propertyFilters: propertyFilters,
|
||||||
|
isDiscoveringServices: isDiscoveringServices,
|
||||||
onTap: (service, characteristic) {
|
onTap: (service, characteristic) {
|
||||||
setState(() {
|
setState(() {
|
||||||
selectedService = service;
|
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() {
|
Widget _buildSelectedCharacteristicCard() {
|
||||||
final colorScheme = Theme.of(context).colorScheme;
|
final colorScheme = Theme.of(context).colorScheme;
|
||||||
if (selectedCharacteristic == null) return const SizedBox.shrink();
|
if (selectedCharacteristic == null) return const SizedBox.shrink();
|
||||||
|
final canNavigate = _canNavigateCharacteristics();
|
||||||
return Card(
|
return Card(
|
||||||
elevation: 2,
|
elevation: 2,
|
||||||
shape: RoundedRectangleBorder(
|
shape: RoundedRectangleBorder(
|
||||||
@@ -1530,6 +1752,52 @@ class _PeripheralDetailPageState extends State<PeripheralDetailPage> {
|
|||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
mainAxisSize: MainAxisSize.min,
|
mainAxisSize: MainAxisSize.min,
|
||||||
children: [
|
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(
|
Row(
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
children: [
|
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(
|
Icon(
|
||||||
Icons.arrow_drop_down,
|
Icons.arrow_drop_down,
|
||||||
size: 18,
|
size: 18,
|
||||||
@@ -1587,52 +1882,6 @@ class _PeripheralDetailPageState extends State<PeripheralDetailPage> {
|
|||||||
],
|
],
|
||||||
),
|
),
|
||||||
const SizedBox(height: 8),
|
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(
|
Row(
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
children: [
|
children: [
|
||||||
@@ -1700,6 +1949,14 @@ class _PeripheralDetailPageState extends State<PeripheralDetailPage> {
|
|||||||
results: _logs,
|
results: _logs,
|
||||||
scrollController: _logsScrollController,
|
scrollController: _logsScrollController,
|
||||||
scrollable: scrollable,
|
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) {
|
onClearTap: (int? index) {
|
||||||
setState(() {
|
setState(() {
|
||||||
if (index != null) {
|
if (index != null) {
|
||||||
|
|||||||
@@ -5,9 +5,11 @@ class ResultWidget extends StatelessWidget {
|
|||||||
final bool scrollable;
|
final bool scrollable;
|
||||||
final ScrollController scrollController;
|
final ScrollController scrollController;
|
||||||
final void Function(int? index) onClearTap;
|
final void Function(int? index) onClearTap;
|
||||||
|
final void Function()? onCopyTap;
|
||||||
const ResultWidget({
|
const ResultWidget({
|
||||||
required this.results,
|
required this.results,
|
||||||
required this.onClearTap,
|
required this.onClearTap,
|
||||||
|
this.onCopyTap,
|
||||||
this.scrollable = false,
|
this.scrollable = false,
|
||||||
required this.scrollController,
|
required this.scrollController,
|
||||||
super.key,
|
super.key,
|
||||||
@@ -64,6 +66,23 @@ class ResultWidget extends StatelessWidget {
|
|||||||
color: colorScheme.onSurface,
|
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(),
|
const Spacer(),
|
||||||
if (results.isNotEmpty)
|
if (results.isNotEmpty)
|
||||||
Container(
|
Container(
|
||||||
@@ -88,7 +107,7 @@ class ResultWidget extends StatelessWidget {
|
|||||||
const SizedBox(width: 8),
|
const SizedBox(width: 8),
|
||||||
IconButton(
|
IconButton(
|
||||||
icon: Icon(
|
icon: Icon(
|
||||||
Icons.clear_all,
|
Icons.close,
|
||||||
color: colorScheme.error,
|
color: colorScheme.error,
|
||||||
size: 20,
|
size: 20,
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -1,9 +1,8 @@
|
|||||||
import 'package:expandable/expandable.dart';
|
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:universal_ble/universal_ble.dart';
|
import 'package:universal_ble/universal_ble.dart';
|
||||||
import 'package:universal_ble_example/data/utils.dart';
|
import 'package:universal_ble_example/data/utils.dart';
|
||||||
|
|
||||||
class ServicesListWidget extends StatelessWidget {
|
class ServicesListWidget extends StatefulWidget {
|
||||||
final List<BleService> discoveredServices;
|
final List<BleService> discoveredServices;
|
||||||
final bool scrollable;
|
final bool scrollable;
|
||||||
final void Function(BleService service, BleCharacteristic characteristic)?
|
final void Function(BleService service, BleCharacteristic characteristic)?
|
||||||
@@ -13,6 +12,8 @@ class ServicesListWidget extends StatelessWidget {
|
|||||||
final Set<String>? favoriteServices;
|
final Set<String>? favoriteServices;
|
||||||
final Map<String, bool>? subscribedCharacteristics;
|
final Map<String, bool>? subscribedCharacteristics;
|
||||||
final void Function(String serviceUuid)? onFavoriteToggle;
|
final void Function(String serviceUuid)? onFavoriteToggle;
|
||||||
|
final Set<CharacteristicProperty>? propertyFilters;
|
||||||
|
final bool isDiscoveringServices;
|
||||||
|
|
||||||
const ServicesListWidget({
|
const ServicesListWidget({
|
||||||
super.key,
|
super.key,
|
||||||
@@ -24,8 +25,151 @@ class ServicesListWidget extends StatelessWidget {
|
|||||||
this.favoriteServices,
|
this.favoriteServices,
|
||||||
this.subscribedCharacteristics,
|
this.subscribedCharacteristics,
|
||||||
this.onFavoriteToggle,
|
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
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final colorScheme = Theme.of(context).colorScheme;
|
final colorScheme = Theme.of(context).colorScheme;
|
||||||
@@ -35,29 +179,99 @@ class ServicesListWidget extends StatelessWidget {
|
|||||||
final selectedCharacteristicBackgroundColor =
|
final selectedCharacteristicBackgroundColor =
|
||||||
colorScheme.primaryContainer.withValues(alpha: 0.5);
|
colorScheme.primaryContainer.withValues(alpha: 0.5);
|
||||||
|
|
||||||
// Sort services: favorites first, then system services, then others
|
final sortedServices = _getSortedServices();
|
||||||
final sortedServices = List<BleService>.from(discoveredServices);
|
|
||||||
sortedServices.sort((a, b) {
|
// Show loading indicator when discovering services and list is empty
|
||||||
final aIsFavorite = favoriteServices?.contains(a.uuid) ?? false;
|
if (widget.isDiscoveringServices && sortedServices.isEmpty) {
|
||||||
final bIsFavorite = favoriteServices?.contains(b.uuid) ?? false;
|
return Center(
|
||||||
if (aIsFavorite != bIsFavorite) {
|
child: Column(
|
||||||
return aIsFavorite ? -1 : 1;
|
mainAxisAlignment: MainAxisAlignment.center,
|
||||||
}
|
children: [
|
||||||
final aIsSystem = isSystemService(a.uuid);
|
CircularProgressIndicator(
|
||||||
final bIsSystem = isSystemService(b.uuid);
|
color: colorScheme.primary,
|
||||||
if (aIsSystem != bIsSystem) {
|
),
|
||||||
return aIsSystem ? 1 : -1;
|
const SizedBox(height: 16),
|
||||||
}
|
Text(
|
||||||
return 0;
|
'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<BleService> sortedServices,
|
||||||
|
ColorScheme colorScheme,
|
||||||
|
Color favoriteStarColor,
|
||||||
|
Color subscribedNotificationIconColor,
|
||||||
|
Color selectedColor,
|
||||||
|
Color selectedCharacteristicBackgroundColor,
|
||||||
|
) {
|
||||||
return ListView.builder(
|
return ListView.builder(
|
||||||
shrinkWrap: !scrollable,
|
controller: _scrollController,
|
||||||
physics: scrollable ? null : const NeverScrollableScrollPhysics(),
|
shrinkWrap: !widget.scrollable,
|
||||||
|
physics: widget.scrollable ? null : const NeverScrollableScrollPhysics(),
|
||||||
itemCount: sortedServices.length,
|
itemCount: sortedServices.length,
|
||||||
itemBuilder: (BuildContext context, int index) {
|
itemBuilder: (BuildContext context, int index) {
|
||||||
final service = sortedServices[index];
|
final service = sortedServices[index];
|
||||||
final isFavorite = favoriteServices?.contains(service.uuid) ?? false;
|
final isFavorite =
|
||||||
final isSelected = selectedService?.uuid == service.uuid;
|
widget.favoriteServices?.contains(service.uuid) ?? false;
|
||||||
|
final isSelected = widget.selectedService?.uuid == service.uuid;
|
||||||
|
final controller =
|
||||||
|
_expandableControllers[service.uuid] ?? ExpansibleController();
|
||||||
return Padding(
|
return Padding(
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 8.0, vertical: 4.0),
|
padding: const EdgeInsets.symmetric(horizontal: 8.0, vertical: 4.0),
|
||||||
child: Card(
|
child: Card(
|
||||||
@@ -68,17 +282,21 @@ class ServicesListWidget extends StatelessWidget {
|
|||||||
? BorderSide(color: selectedColor, width: 2)
|
? BorderSide(color: selectedColor, width: 2)
|
||||||
: BorderSide.none,
|
: BorderSide.none,
|
||||||
),
|
),
|
||||||
child: ExpandablePanel(
|
child: Theme(
|
||||||
header: Padding(
|
data: Theme.of(context).copyWith(
|
||||||
padding: const EdgeInsets.all(12.0),
|
splashFactory: NoSplash.splashFactory,
|
||||||
child: Row(
|
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: [
|
children: [
|
||||||
Icon(
|
|
||||||
Icons.arrow_forward_ios,
|
|
||||||
size: 14,
|
|
||||||
color: colorScheme.onSurface.withValues(alpha: 0.6),
|
|
||||||
),
|
|
||||||
const SizedBox(width: 8),
|
|
||||||
Expanded(
|
Expanded(
|
||||||
child: Text(
|
child: Text(
|
||||||
service.uuid,
|
service.uuid,
|
||||||
@@ -91,7 +309,7 @@ class ServicesListWidget extends StatelessWidget {
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
if (onFavoriteToggle != null) ...[
|
if (widget.onFavoriteToggle != null) ...[
|
||||||
const SizedBox(width: 8),
|
const SizedBox(width: 8),
|
||||||
IconButton(
|
IconButton(
|
||||||
icon: Icon(
|
icon: Icon(
|
||||||
@@ -100,32 +318,47 @@ class ServicesListWidget extends StatelessWidget {
|
|||||||
? favoriteStarColor
|
? favoriteStarColor
|
||||||
: colorScheme.onSurface.withValues(alpha: 0.4),
|
: colorScheme.onSurface.withValues(alpha: 0.4),
|
||||||
),
|
),
|
||||||
onPressed: () => onFavoriteToggle!(service.uuid),
|
onPressed: () => widget.onFavoriteToggle!(service.uuid),
|
||||||
iconSize: 20,
|
iconSize: 20,
|
||||||
padding: EdgeInsets.zero,
|
padding: EdgeInsets.zero,
|
||||||
constraints: const BoxConstraints(
|
constraints: const BoxConstraints(
|
||||||
minWidth: 32,
|
minWidth: 32,
|
||||||
minHeight: 32,
|
minHeight: 32,
|
||||||
),
|
),
|
||||||
|
splashColor: Colors.transparent,
|
||||||
|
highlightColor: Colors.transparent,
|
||||||
|
hoverColor: Colors.transparent,
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
childrenPadding: const EdgeInsets.only(bottom: 8.0),
|
||||||
collapsed: const SizedBox(),
|
children: service.characteristics.where((e) {
|
||||||
expanded: Padding(
|
// Filter by properties if filters are selected
|
||||||
padding: const EdgeInsets.only(bottom: 8.0),
|
if (widget.propertyFilters != null &&
|
||||||
child: Column(
|
widget.propertyFilters!.isNotEmpty) {
|
||||||
children: service.characteristics.map((e) {
|
return e.properties
|
||||||
final isCharSelected =
|
.any((prop) => widget.propertyFilters!.contains(prop));
|
||||||
selectedCharacteristic?.uuid == e.uuid;
|
}
|
||||||
final isSubscribed =
|
return true;
|
||||||
subscribedCharacteristics?[e.uuid] ?? false;
|
}).map((e) {
|
||||||
return Padding(
|
final isCharSelected =
|
||||||
padding: const EdgeInsets.symmetric(
|
widget.selectedCharacteristic?.uuid == e.uuid;
|
||||||
horizontal: 8.0,
|
final isSubscribed =
|
||||||
vertical: 4.0,
|
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(
|
child: Container(
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
color: isCharSelected
|
color: isCharSelected
|
||||||
@@ -137,86 +370,79 @@ class ServicesListWidget extends StatelessWidget {
|
|||||||
? Border.all(color: selectedColor, width: 1.5)
|
? Border.all(color: selectedColor, width: 1.5)
|
||||||
: null,
|
: null,
|
||||||
),
|
),
|
||||||
child: InkWell(
|
child: Padding(
|
||||||
onTap: () {
|
padding: const EdgeInsets.all(12.0),
|
||||||
onTap?.call(service, e);
|
child: Column(
|
||||||
},
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
borderRadius: BorderRadius.circular(8),
|
children: [
|
||||||
child: Padding(
|
Row(
|
||||||
padding: const EdgeInsets.all(12.0),
|
children: [
|
||||||
child: Column(
|
Icon(
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
Icons.arrow_right_outlined,
|
||||||
children: [
|
size: 16,
|
||||||
Row(
|
color: isCharSelected
|
||||||
children: [
|
? selectedColor
|
||||||
|
: colorScheme.onSurface
|
||||||
|
.withValues(alpha: 0.6),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 8),
|
||||||
|
if (isSubscribed) ...[
|
||||||
Icon(
|
Icon(
|
||||||
Icons.arrow_right_outlined,
|
Icons.notifications_active,
|
||||||
|
color: subscribedNotificationIconColor,
|
||||||
size: 16,
|
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),
|
||||||
],
|
],
|
||||||
),
|
Expanded(
|
||||||
const SizedBox(height: 8),
|
child: Text(
|
||||||
Wrap(
|
e.uuid,
|
||||||
spacing: 6,
|
style: TextStyle(
|
||||||
runSpacing: 6,
|
fontSize: 12,
|
||||||
children: e.properties.map((prop) {
|
fontFamily: 'monospace',
|
||||||
return Container(
|
fontWeight: isCharSelected
|
||||||
padding: const EdgeInsets.symmetric(
|
? FontWeight.bold
|
||||||
horizontal: 6,
|
: FontWeight.w500,
|
||||||
vertical: 2,
|
color: isCharSelected
|
||||||
|
? selectedColor
|
||||||
|
: colorScheme.onSurface,
|
||||||
),
|
),
|
||||||
decoration: BoxDecoration(
|
),
|
||||||
color: colorScheme.secondaryContainer,
|
),
|
||||||
borderRadius: BorderRadius.circular(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),
|
||||||
|
),
|
||||||
|
child: Text(
|
||||||
|
prop.name,
|
||||||
|
style: TextStyle(
|
||||||
|
fontSize: 10,
|
||||||
|
color: colorScheme.onSecondaryContainer,
|
||||||
|
fontWeight: FontWeight.w600,
|
||||||
),
|
),
|
||||||
child: Text(
|
),
|
||||||
prop.name,
|
);
|
||||||
style: TextStyle(
|
}).toList(),
|
||||||
fontSize: 10,
|
),
|
||||||
color:
|
],
|
||||||
colorScheme.onSecondaryContainer,
|
|
||||||
fontWeight: FontWeight.w600,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}).toList(),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
);
|
),
|
||||||
}).toList(),
|
);
|
||||||
),
|
}).toList(),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -1,20 +1,128 @@
|
|||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:universal_ble/universal_ble.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 List<BleService> discoveredServices;
|
||||||
final Function() serviceListBuilder;
|
final Function(Set<CharacteristicProperty>? selectedProperties,
|
||||||
|
GlobalKey<ServicesListWidgetState>? listKey, bool isDiscoveringServices)
|
||||||
|
serviceListBuilder;
|
||||||
final VoidCallback? onCopyServices;
|
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({
|
const ServicesSideWidget({
|
||||||
super.key,
|
super.key,
|
||||||
required this.discoveredServices,
|
required this.discoveredServices,
|
||||||
required this.serviceListBuilder,
|
required this.serviceListBuilder,
|
||||||
this.onCopyServices,
|
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
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final colorScheme = Theme.of(context).colorScheme;
|
final colorScheme = Theme.of(context).colorScheme;
|
||||||
|
final hasActiveFilters =
|
||||||
|
_selectedProperties != null && _selectedProperties!.isNotEmpty;
|
||||||
return Container(
|
return Container(
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
color: colorScheme.surfaceContainerHighest,
|
color: colorScheme.surfaceContainerHighest,
|
||||||
@@ -39,82 +147,245 @@ class ServicesSideWidget extends StatelessWidget {
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
child: Row(
|
child: Column(
|
||||||
children: [
|
children: [
|
||||||
Icon(
|
Row(
|
||||||
Icons.apps,
|
children: [
|
||||||
color: colorScheme.primary,
|
Icon(
|
||||||
size: 20,
|
Icons.apps,
|
||||||
),
|
color: colorScheme.primary,
|
||||||
const SizedBox(width: 8),
|
size: 20,
|
||||||
Text(
|
),
|
||||||
'Services',
|
const SizedBox(width: 8),
|
||||||
style: TextStyle(
|
Text(
|
||||||
fontWeight: FontWeight.bold,
|
'Services',
|
||||||
fontSize: 16,
|
style: TextStyle(
|
||||||
color: colorScheme.onSurface,
|
fontWeight: FontWeight.bold,
|
||||||
),
|
fontSize: 16,
|
||||||
),
|
color: colorScheme.onSurface,
|
||||||
const Spacer(),
|
),
|
||||||
if (onCopyServices != null)
|
),
|
||||||
Padding(
|
const Spacer(),
|
||||||
padding: const EdgeInsets.only(right: 8),
|
// Navigation buttons
|
||||||
child: IconButton(
|
if (_canNavigate()) ...[
|
||||||
onPressed: onCopyServices,
|
IconButton(
|
||||||
icon: const Icon(Icons.copy),
|
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,
|
iconSize: 18,
|
||||||
tooltip: 'Copy Services',
|
tooltip: 'Filter by Properties',
|
||||||
padding: EdgeInsets.zero,
|
padding: EdgeInsets.zero,
|
||||||
constraints: const BoxConstraints(
|
constraints: const BoxConstraints(
|
||||||
minWidth: 32,
|
minWidth: 32,
|
||||||
minHeight: 32,
|
minHeight: 32,
|
||||||
),
|
),
|
||||||
color: colorScheme.onSurface,
|
|
||||||
),
|
),
|
||||||
),
|
if (widget.onCopyServices != null)
|
||||||
Container(
|
Padding(
|
||||||
padding: const EdgeInsets.symmetric(
|
padding: const EdgeInsets.only(right: 8),
|
||||||
horizontal: 8,
|
child: IconButton(
|
||||||
vertical: 4,
|
onPressed: widget.onCopyServices,
|
||||||
),
|
icon: const Icon(Icons.copy),
|
||||||
decoration: BoxDecoration(
|
iconSize: 18,
|
||||||
color: colorScheme.primaryContainer,
|
tooltip: 'Copy Services',
|
||||||
borderRadius: BorderRadius.circular(8),
|
padding: EdgeInsets.zero,
|
||||||
),
|
constraints: const BoxConstraints(
|
||||||
child: Text(
|
minWidth: 32,
|
||||||
'${discoveredServices.length}',
|
minHeight: 32,
|
||||||
style: TextStyle(
|
),
|
||||||
color: colorScheme.onPrimaryContainer,
|
color: colorScheme.onSurface,
|
||||||
fontSize: 12,
|
),
|
||||||
fontWeight: FontWeight.w600,
|
),
|
||||||
|
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,
|
||||||
|
),
|
||||||
|
),
|
||||||
),
|
),
|
||||||
),
|
],
|
||||||
),
|
),
|
||||||
|
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(
|
Expanded(
|
||||||
child: discoveredServices.isEmpty
|
child: widget.discoveredServices.isEmpty
|
||||||
? Center(
|
? Center(
|
||||||
child: Column(
|
child: widget.isDiscoveringServices
|
||||||
mainAxisAlignment: MainAxisAlignment.center,
|
? Column(
|
||||||
children: [
|
mainAxisAlignment: MainAxisAlignment.center,
|
||||||
Icon(
|
children: [
|
||||||
Icons.apps_outlined,
|
CircularProgressIndicator(
|
||||||
size: 48,
|
color: colorScheme.primary,
|
||||||
color: colorScheme.onSurface.withValues(alpha: 0.3),
|
),
|
||||||
),
|
const SizedBox(height: 16),
|
||||||
const SizedBox(height: 16),
|
Text(
|
||||||
Text(
|
'Discovering services...',
|
||||||
'No Services Discovered',
|
style: TextStyle(
|
||||||
style: TextStyle(
|
color: colorScheme.onSurface
|
||||||
color: colorScheme.onSurface.withValues(alpha: 0.6),
|
.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),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -38,11 +38,11 @@ EXTERNAL SOURCES:
|
|||||||
|
|
||||||
SPEC CHECKSUMS:
|
SPEC CHECKSUMS:
|
||||||
FlutterMacOS: d0db08ddef1a9af05a5ec4b724367152bb0500b1
|
FlutterMacOS: d0db08ddef1a9af05a5ec4b724367152bb0500b1
|
||||||
package_info_plus: 12f1c5c2cfe8727ca46cbd0b26677728972d9a5b
|
package_info_plus: f0052d280d17aa382b932f399edf32507174e870
|
||||||
path_provider_foundation: 0b743cbb62d8e47eab856f09262bb8c1ddcfe6ba
|
path_provider_foundation: bb55f6dbba17d0dccd6737fe6f7f34fbd0376880
|
||||||
shared_preferences_foundation: 5086985c1d43c5ba4d5e69a4e8083a389e2909e6
|
shared_preferences_foundation: 7036424c3d8ec98dfe75ff1667cb0cd531ec82bb
|
||||||
universal_ble: 65e1257dffc557cc7991a93d253beeddc7c1dc92
|
universal_ble: 45519b2aeafe62761e2c6309f8927edb5288b914
|
||||||
url_launcher_macos: 175a54c831f4375a6cf895875f716ee5af3888ce
|
url_launcher_macos: f87a979182d112f911de6820aefddaf56ee9fbfd
|
||||||
|
|
||||||
PODFILE CHECKSUM: 9ebaf0ce3d369aaa26a9ea0e159195ed94724cf3
|
PODFILE CHECKSUM: 9ebaf0ce3d369aaa26a9ea0e159195ed94724cf3
|
||||||
|
|
||||||
|
|||||||
@@ -145,14 +145,6 @@ packages:
|
|||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "1.0.0"
|
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:
|
fake_async:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
|
|||||||
@@ -10,7 +10,6 @@ dependencies:
|
|||||||
flutter:
|
flutter:
|
||||||
sdk: flutter
|
sdk: flutter
|
||||||
convert: ^3.1.1
|
convert: ^3.1.1
|
||||||
expandable: ^5.0.1
|
|
||||||
cupertino_icons: ^1.0.2
|
cupertino_icons: ^1.0.2
|
||||||
shared_preferences: ^2.5.4
|
shared_preferences: ^2.5.4
|
||||||
package_info_plus: ^9.0.0
|
package_info_plus: ^9.0.0
|
||||||
|
|||||||
Reference in New Issue
Block a user