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:
Foti Dim
2026-01-23 07:29:28 +01:00
committed by GitHub
parent 2f7f1f693d
commit bcf65e4573
10 changed files with 1252 additions and 354 deletions
+28 -16
View File
@@ -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
+97
View File
@@ -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];
}
}
+26 -1
View File
@@ -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),
),
],
),
+5 -5
View File
@@ -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
-8
View File
@@ -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:
-1
View File
@@ -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