8c75e3965f
- Added a new `prefs_async.dart` file to encapsulate the usage of `SharedPreferencesAsync`. - Updated `ScanController`, `StorageService`, and `ScannerScreen` to utilize the new async preferences methods, enhancing performance and code clarity. - Modified `devtools_options.yaml` to enable the `shared_preferences` extension. - Refactored the `FlashOnUpdateWidget` to improve the outline animation and added a customizable border radius. - Adjusted the favorite services loading mechanism in `PeripheralDetailPage` to be asynchronous, ensuring better state management.
2157 lines
78 KiB
Dart
2157 lines
78 KiB
Dart
import 'dart:async';
|
|
|
|
import 'package:convert/convert.dart';
|
|
import 'package:flutter/foundation.dart';
|
|
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_header_bar.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';
|
|
import 'package:universal_ble_example/data/scan_controller.dart';
|
|
import 'package:universal_ble_example/widgets/company_info_widget.dart';
|
|
import 'package:universal_ble_example/widgets/responsive_view.dart';
|
|
import 'package:universal_ble_example/widgets/scan_controller_scope.dart';
|
|
|
|
class PeripheralDetailPage extends StatefulWidget {
|
|
final BleDevice bleDevice;
|
|
const PeripheralDetailPage(this.bleDevice, {super.key});
|
|
|
|
@override
|
|
State<StatefulWidget> createState() {
|
|
return _PeripheralDetailPageState();
|
|
}
|
|
}
|
|
|
|
class _PeripheralDetailPageState extends State<PeripheralDetailPage> {
|
|
late final bleDevice = widget.bleDevice;
|
|
ScanController? _scanController;
|
|
void _onScanStateChanged() => setState(() {});
|
|
|
|
bool isConnected = false;
|
|
GlobalKey<FormState> valueFormKey = GlobalKey<FormState>();
|
|
List<BleService> discoveredServices = [];
|
|
final List<String> _logs = [];
|
|
final binaryCode = TextEditingController();
|
|
bool _isDiscoveringServices = false;
|
|
bool _isDeviceInfoExpanded = false;
|
|
bool _isDeviceActionsExpanded = true;
|
|
bool _isReceivedAdvertisementsExpanded = true;
|
|
final Map<String, bool> _subscribedCharacteristics = {};
|
|
bool _autoConnect = false;
|
|
|
|
StreamSubscription? connectionStreamSubscription;
|
|
StreamSubscription? pairingStateSubscription;
|
|
StreamSubscription<BleDevice>? _advertisementSubscription;
|
|
List<BleDevice> _receivedAdvertisements = [];
|
|
BleService? selectedService;
|
|
BleCharacteristic? selectedCharacteristic;
|
|
final ScrollController _logsScrollController = ScrollController();
|
|
final Set<String> _favoriteServices = {};
|
|
Set<CharacteristicProperty>? _currentPropertyFilters;
|
|
|
|
Future<void> _loadFavoriteServices() async {
|
|
final favorites = await StorageService.instance.getFavoriteServices();
|
|
if (mounted) {
|
|
setState(() {
|
|
_favoriteServices.addAll(favorites);
|
|
});
|
|
}
|
|
}
|
|
|
|
Future<void> _saveFavoriteServices() async {
|
|
await StorageService.instance.setFavoriteServices(
|
|
_favoriteServices.toList(),
|
|
);
|
|
}
|
|
|
|
@override
|
|
void initState() {
|
|
super.initState();
|
|
|
|
_receivedAdvertisements = [bleDevice];
|
|
_advertisementSubscription = UniversalBle.scanStream
|
|
.where((d) => d.deviceId == bleDevice.deviceId)
|
|
.listen((d) {
|
|
if (mounted) {
|
|
setState(() {
|
|
_receivedAdvertisements.add(d);
|
|
});
|
|
}
|
|
});
|
|
|
|
connectionStreamSubscription = bleDevice.connectionStream.listen(
|
|
_handleConnectionChange,
|
|
);
|
|
pairingStateSubscription = bleDevice.pairingStateStream.listen(
|
|
_handlePairingStateChange,
|
|
);
|
|
UniversalBle.onValueChange = _handleValueChange;
|
|
|
|
bleDevice.connectionState.then((state) {
|
|
_handleConnectionChange(state == BleConnectionState.connected);
|
|
});
|
|
_loadFavoriteServices();
|
|
}
|
|
|
|
@override
|
|
void didChangeDependencies() {
|
|
super.didChangeDependencies();
|
|
_scanController ??= ScanControllerScope.of(context)
|
|
..addListener(_onScanStateChanged);
|
|
}
|
|
|
|
@override
|
|
void dispose() {
|
|
_scanController?.removeListener(_onScanStateChanged);
|
|
_advertisementSubscription?.cancel();
|
|
connectionStreamSubscription?.cancel();
|
|
pairingStateSubscription?.cancel();
|
|
UniversalBle.onValueChange = null;
|
|
super.dispose();
|
|
}
|
|
|
|
void _addLog(String type, dynamic data) {
|
|
setState(() {
|
|
_logs.add('$type: ${data.toString()}');
|
|
});
|
|
}
|
|
|
|
void _handleConnectionChange(bool isConnected) {
|
|
debugPrint('_handleConnectionChange $isConnected');
|
|
setState(() {
|
|
this.isConnected = isConnected;
|
|
});
|
|
_addLog('Connection', isConnected ? "Connected" : "Disconnected");
|
|
// Auto Discover Services
|
|
if (this.isConnected) {
|
|
_discoverServices();
|
|
}
|
|
}
|
|
|
|
void _handleValueChange(
|
|
String deviceId,
|
|
String characteristicId,
|
|
Uint8List value,
|
|
int? timestamp,
|
|
) {
|
|
String data = _formatReadValue(value);
|
|
DateTime? timestampDateTime = timestamp != null
|
|
? DateTime.fromMillisecondsSinceEpoch(timestamp)
|
|
: null;
|
|
// 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);
|
|
}
|
|
|
|
void _handlePairingStateChange(bool isPaired) {
|
|
debugPrint('isPaired $isPaired');
|
|
_addLog("PairingStateChange - isPaired", isPaired);
|
|
}
|
|
|
|
Future<void> _discoverServices() async {
|
|
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);
|
|
debugPrint('${services.length} services discovered');
|
|
debugPrint(_formattedServices(services));
|
|
setState(() {
|
|
discoveredServices = services;
|
|
});
|
|
|
|
// Build log message step-by-step for clarity
|
|
final StringBuffer logMessage = StringBuffer();
|
|
logMessage.write('${services.length} services discovered');
|
|
if (kIsWeb) {
|
|
logMessage.write(',\n$webWarning');
|
|
}
|
|
logMessage.write('\n\n${_formattedServices(services)}');
|
|
|
|
_addLog("DiscoverServices", logMessage.toString());
|
|
},
|
|
onError: (error) {
|
|
final StringBuffer errorMessage = StringBuffer();
|
|
errorMessage.write(error);
|
|
if (kIsWeb) {
|
|
errorMessage.write('\n$webWarning');
|
|
}
|
|
_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 {
|
|
BleCharacteristic? selectedCharacteristic = this.selectedCharacteristic;
|
|
if (selectedCharacteristic == null) return;
|
|
await _executeWithLoading(
|
|
() async {
|
|
Uint8List value = await selectedCharacteristic.read();
|
|
String data = _formatReadValue(value);
|
|
_addLog('Read', data);
|
|
},
|
|
onError: (error) {
|
|
_addLog('ReadError', error);
|
|
},
|
|
);
|
|
}
|
|
|
|
Future<void> _writeValue() async {
|
|
BleCharacteristic? selectedCharacteristic = this.selectedCharacteristic;
|
|
if (selectedCharacteristic == null ||
|
|
!valueFormKey.currentState!.validate() ||
|
|
binaryCode.text.isEmpty) {
|
|
return;
|
|
}
|
|
|
|
Uint8List value;
|
|
try {
|
|
value = Uint8List.fromList(hex.decode(binaryCode.text));
|
|
} catch (e) {
|
|
_addLog('WriteError', "Error parsing hex $e");
|
|
return;
|
|
}
|
|
|
|
bool writeWithResponse = true;
|
|
if (!selectedCharacteristic.properties.contains(
|
|
CharacteristicProperty.write,
|
|
)) {
|
|
writeWithResponse = false;
|
|
}
|
|
|
|
await _executeWithLoading(
|
|
() async {
|
|
await selectedCharacteristic.write(
|
|
value,
|
|
withResponse: writeWithResponse,
|
|
);
|
|
_addLog('Write${writeWithResponse ? "" : "WithoutResponse"}', value);
|
|
},
|
|
onError: (error) {
|
|
_addLog('WriteError', error);
|
|
},
|
|
);
|
|
}
|
|
|
|
Future<void> _subscribeChar() async {
|
|
BleCharacteristic? selectedCharacteristic = this.selectedCharacteristic;
|
|
if (selectedCharacteristic == null) return;
|
|
await _executeWithLoading(
|
|
() async {
|
|
var subscription = _getCharacteristicSubscription(
|
|
selectedCharacteristic,
|
|
);
|
|
if (subscription == null) throw 'No notify or indicate property';
|
|
await subscription.subscribe();
|
|
setState(() {
|
|
_subscribedCharacteristics[selectedCharacteristic.uuid] = true;
|
|
});
|
|
_addLog('BleCharSubscription', 'Subscribed');
|
|
},
|
|
onError: (error) {
|
|
_addLog('NotifyError', error);
|
|
},
|
|
);
|
|
}
|
|
|
|
Future<void> _unsubscribeChar() async {
|
|
final char = selectedCharacteristic;
|
|
if (char == null) return;
|
|
await _executeWithLoading(
|
|
() async {
|
|
await char.unsubscribe();
|
|
setState(() {
|
|
_subscribedCharacteristics.remove(char.uuid);
|
|
});
|
|
_addLog('BleCharSubscription', 'UnSubscribed');
|
|
},
|
|
onError: (error) {
|
|
_addLog('NotifyError', error);
|
|
},
|
|
);
|
|
}
|
|
|
|
Future<void> _subscribeToAllCharacteristics() async {
|
|
if (!isConnected) return;
|
|
await _executeWithLoading(
|
|
() async {
|
|
int successCount = 0;
|
|
int errorCount = 0;
|
|
for (var service in discoveredServices) {
|
|
for (var characteristic in service.characteristics) {
|
|
var subscription = _getCharacteristicSubscription(characteristic);
|
|
if (subscription != null) {
|
|
try {
|
|
await subscription.subscribe();
|
|
setState(() {
|
|
_subscribedCharacteristics[characteristic.uuid] = true;
|
|
});
|
|
successCount++;
|
|
} catch (e) {
|
|
errorCount++;
|
|
debugPrint('Failed to subscribe to ${characteristic.uuid}: $e');
|
|
}
|
|
}
|
|
}
|
|
}
|
|
_addLog(
|
|
'BleCharSubscription',
|
|
'Subscribed to $successCount characteristics${errorCount > 0 ? ', $errorCount failed' : ''}',
|
|
);
|
|
},
|
|
onError: (error) {
|
|
_addLog('SubscribeToAllCharacteristicsError', error);
|
|
},
|
|
);
|
|
}
|
|
|
|
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,
|
|
) {
|
|
var properties = characteristic.properties;
|
|
if (properties.contains(CharacteristicProperty.notify)) {
|
|
return characteristic.notifications;
|
|
} else if (properties.contains(CharacteristicProperty.indicate)) {
|
|
return characteristic.indications;
|
|
}
|
|
return null;
|
|
}
|
|
|
|
Future<T> _executeWithLoading<T>(
|
|
Future<T> Function() action, {
|
|
Function(dynamic error)? onError,
|
|
}) async {
|
|
try {
|
|
return await action();
|
|
} catch (e) {
|
|
onError?.call(e);
|
|
rethrow;
|
|
}
|
|
}
|
|
|
|
void _showCharacteristicSelector() {
|
|
showModalBottomSheet(
|
|
context: context,
|
|
shape: const RoundedRectangleBorder(
|
|
borderRadius: BorderRadius.vertical(top: Radius.circular(20)),
|
|
),
|
|
builder: (context) {
|
|
return Padding(
|
|
padding: EdgeInsets.only(bottom: 10.0),
|
|
child: ServicesSideWidget(
|
|
discoveredServices: discoveredServices,
|
|
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();
|
|
if (context.mounted) {
|
|
Navigator.pop(context);
|
|
}
|
|
}
|
|
: null,
|
|
),
|
|
);
|
|
},
|
|
);
|
|
}
|
|
|
|
Future<void> _copyServicesToClipboard() async {
|
|
final servicesText = _formattedServices(discoveredServices);
|
|
await Clipboard.setData(
|
|
ClipboardData(text: servicesText),
|
|
);
|
|
if (context.mounted) {
|
|
_showSnackBar('All services copied to clipboard');
|
|
}
|
|
}
|
|
|
|
void _showSnackBar(String message) {
|
|
if (context.mounted) {
|
|
ScaffoldMessenger.of(context).showSnackBar(
|
|
SnackBar(
|
|
content: Text(message),
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
String _formattedServices(List<BleService> services) {
|
|
final buffer = StringBuffer();
|
|
for (var service in services) {
|
|
buffer.writeln('Service: ${service.uuid}');
|
|
for (var characteristic in service.characteristics) {
|
|
final properties =
|
|
characteristic.properties.map((p) => p.name).join(', ');
|
|
buffer.writeln(' ${characteristic.uuid} ($properties)');
|
|
}
|
|
buffer.writeln();
|
|
}
|
|
return buffer.toString().trim();
|
|
}
|
|
|
|
@override
|
|
void setState(VoidCallback fn) {
|
|
if (mounted) super.setState(fn);
|
|
}
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
final colorScheme = Theme.of(context).colorScheme;
|
|
return ResponsiveView(
|
|
builder: (_, DeviceType deviceType) {
|
|
return Scaffold(
|
|
appBar: AppBar(
|
|
title: Text(
|
|
bleDevice.name ?? "Unknown Device",
|
|
maxLines: 1,
|
|
overflow: TextOverflow.ellipsis,
|
|
style: const TextStyle(
|
|
fontWeight: FontWeight.bold,
|
|
letterSpacing: 0.5,
|
|
),
|
|
),
|
|
centerTitle: false,
|
|
elevation: 0,
|
|
actions: [
|
|
// Persistent scan button
|
|
if (_scanController != null)
|
|
Padding(
|
|
padding: const EdgeInsets.only(right: 8.0),
|
|
child: FilledButton.icon(
|
|
onPressed: () async {
|
|
if (_scanController!.isScanning) {
|
|
await _scanController!.stopScan();
|
|
} else {
|
|
try {
|
|
await _scanController!.startScan();
|
|
} catch (e) {
|
|
if (!context.mounted) return;
|
|
ScaffoldMessenger.of(context).showSnackBar(
|
|
SnackBar(content: Text(e.toString())),
|
|
);
|
|
}
|
|
}
|
|
},
|
|
icon: Icon(
|
|
_scanController!.isScanning
|
|
? Icons.stop_circle
|
|
: Icons.play_arrow,
|
|
size: 20,
|
|
),
|
|
label: Text(
|
|
_scanController!.isScanning ? 'Stop' : 'Scan',
|
|
style: const TextStyle(
|
|
fontSize: 14,
|
|
fontWeight: FontWeight.w600,
|
|
),
|
|
),
|
|
style: FilledButton.styleFrom(
|
|
backgroundColor: _scanController!.isScanning
|
|
? colorScheme.error
|
|
: colorScheme.primary,
|
|
foregroundColor: _scanController!.isScanning
|
|
? colorScheme.onError
|
|
: colorScheme.onPrimary,
|
|
padding: const EdgeInsets.symmetric(
|
|
horizontal: 12,
|
|
vertical: 12,
|
|
),
|
|
shape: RoundedRectangleBorder(
|
|
borderRadius: BorderRadius.circular(8),
|
|
),
|
|
elevation: _scanController!.isScanning ? 0 : 2,
|
|
),
|
|
),
|
|
),
|
|
],
|
|
),
|
|
body: Row(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
// Services side widget on desktop
|
|
if (deviceType == DeviceType.desktop)
|
|
Expanded(
|
|
flex: 1,
|
|
child: ServicesSideWidget(
|
|
discoveredServices: discoveredServices,
|
|
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,
|
|
),
|
|
),
|
|
// Main content
|
|
Expanded(
|
|
flex: 2,
|
|
child: SingleChildScrollView(
|
|
child: Column(
|
|
children: [
|
|
// Device info with manufacturer data and advertised services
|
|
_buildDeviceInfo(),
|
|
|
|
// Received advertisements list
|
|
_buildReceivedAdvertisements(),
|
|
|
|
// Connect/Disconnect button
|
|
_buildConnectDisconnectButton(),
|
|
|
|
// Device Actions
|
|
_buildDeviceActions(),
|
|
|
|
// Characteristic selector
|
|
_buildCharacteristicSelector(),
|
|
|
|
// Characteristic Actions
|
|
_buildCharacteristicActions(),
|
|
|
|
// Logs on bottom for mobile
|
|
if (deviceType != DeviceType.desktop) ...[
|
|
const Divider(),
|
|
_buildResultWidget(scrollable: false),
|
|
],
|
|
const SizedBox(height: 20),
|
|
],
|
|
),
|
|
),
|
|
),
|
|
|
|
// Logs on right side for desktop
|
|
if (deviceType == DeviceType.desktop)
|
|
Expanded(
|
|
flex: 1,
|
|
child: _buildResultWidget(
|
|
scrollable: true,
|
|
),
|
|
),
|
|
],
|
|
),
|
|
);
|
|
},
|
|
);
|
|
}
|
|
|
|
Widget _buildCharacteristicActions() {
|
|
final colorScheme = Theme.of(context).colorScheme;
|
|
return Padding(
|
|
padding: const EdgeInsets.symmetric(
|
|
horizontal: 16.0,
|
|
vertical: 8.0,
|
|
),
|
|
child: Card(
|
|
elevation: 2,
|
|
shape: RoundedRectangleBorder(
|
|
borderRadius: BorderRadius.circular(12),
|
|
),
|
|
child: Padding(
|
|
padding: const EdgeInsets.all(16.0),
|
|
child: Column(
|
|
spacing: 12,
|
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
|
children: [
|
|
Row(
|
|
children: [
|
|
Icon(
|
|
Icons.tune,
|
|
color: colorScheme.primary,
|
|
size: 20,
|
|
),
|
|
const SizedBox(width: 8),
|
|
Text(
|
|
'Characteristic Actions',
|
|
style: TextStyle(
|
|
fontWeight: FontWeight.bold,
|
|
fontSize: 16,
|
|
color: colorScheme.onSurface,
|
|
),
|
|
),
|
|
],
|
|
),
|
|
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: [
|
|
Row(
|
|
spacing: 12,
|
|
children: [
|
|
Expanded(
|
|
flex: 3,
|
|
child: TextFormField(
|
|
controller: binaryCode,
|
|
enabled: isConnected &&
|
|
_hasSelectedCharacteristicProperty([
|
|
CharacteristicProperty.write,
|
|
CharacteristicProperty.writeWithoutResponse,
|
|
]),
|
|
validator: (value) {
|
|
if (value == null || value.isEmpty) {
|
|
return 'Please enter a value';
|
|
}
|
|
try {
|
|
hex.decode(binaryCode.text);
|
|
return null;
|
|
} catch (e) {
|
|
return 'Please enter a valid hex value ( without spaces or 0x (e.g. F0BB) )';
|
|
}
|
|
},
|
|
decoration: InputDecoration(
|
|
hintText:
|
|
"Enter Hex values without spaces or 0x (e.g. F0BB)",
|
|
border: OutlineInputBorder(
|
|
borderRadius: BorderRadius.circular(12),
|
|
),
|
|
filled: true,
|
|
fillColor: colorScheme.surfaceContainerHighest,
|
|
isDense: true,
|
|
contentPadding: const EdgeInsets.symmetric(
|
|
horizontal: 12,
|
|
vertical: 12,
|
|
),
|
|
prefixIcon: Icon(
|
|
Icons.code,
|
|
color: colorScheme.primary,
|
|
),
|
|
),
|
|
),
|
|
),
|
|
Expanded(
|
|
flex: 1,
|
|
child: ElevatedButton.icon(
|
|
onPressed: isConnected &&
|
|
_hasSelectedCharacteristicProperty([
|
|
CharacteristicProperty.write,
|
|
CharacteristicProperty
|
|
.writeWithoutResponse,
|
|
])
|
|
? _writeValue
|
|
: null,
|
|
icon: const Icon(Icons.send),
|
|
label: const Text('Write'),
|
|
style: ElevatedButton.styleFrom(
|
|
backgroundColor: colorScheme.primary,
|
|
foregroundColor: colorScheme.onPrimary,
|
|
padding: const EdgeInsets.symmetric(
|
|
vertical: 16,
|
|
),
|
|
shape: RoundedRectangleBorder(
|
|
borderRadius: BorderRadius.circular(12),
|
|
),
|
|
),
|
|
),
|
|
),
|
|
],
|
|
),
|
|
],
|
|
),
|
|
),
|
|
Wrap(
|
|
spacing: 12,
|
|
runSpacing: 12,
|
|
children: [
|
|
ElevatedButton.icon(
|
|
onPressed: isConnected &&
|
|
discoveredServices.isNotEmpty &&
|
|
selectedCharacteristic != null &&
|
|
_hasSelectedCharacteristicProperty([
|
|
CharacteristicProperty.notify,
|
|
CharacteristicProperty.indicate,
|
|
])
|
|
? _subscribeChar
|
|
: null,
|
|
icon: const Icon(Icons.notifications_active),
|
|
label: const Text('Subscribe'),
|
|
style: ElevatedButton.styleFrom(
|
|
backgroundColor: Colors.green,
|
|
foregroundColor: Colors.white,
|
|
padding: const EdgeInsets.symmetric(
|
|
horizontal: 16,
|
|
vertical: 12,
|
|
),
|
|
shape: RoundedRectangleBorder(
|
|
borderRadius: BorderRadius.circular(8),
|
|
),
|
|
),
|
|
),
|
|
OutlinedButton.icon(
|
|
onPressed: isConnected &&
|
|
discoveredServices.isNotEmpty &&
|
|
selectedCharacteristic != null &&
|
|
_hasSelectedCharacteristicProperty([
|
|
CharacteristicProperty.notify,
|
|
CharacteristicProperty.indicate,
|
|
])
|
|
? _unsubscribeChar
|
|
: null,
|
|
icon: const Icon(Icons.notifications_off),
|
|
label: const Text('Unsubscribe'),
|
|
style: OutlinedButton.styleFrom(
|
|
foregroundColor: colorScheme.onSurface,
|
|
padding: const EdgeInsets.symmetric(
|
|
horizontal: 16,
|
|
vertical: 12,
|
|
),
|
|
shape: RoundedRectangleBorder(
|
|
borderRadius: BorderRadius.circular(8),
|
|
),
|
|
),
|
|
),
|
|
ElevatedButton.icon(
|
|
onPressed: isConnected && discoveredServices.isNotEmpty
|
|
? _subscribeToAllCharacteristics
|
|
: null,
|
|
icon: const Icon(Icons.notifications_active),
|
|
label: const Text('Subscribe All'),
|
|
style: ElevatedButton.styleFrom(
|
|
backgroundColor: Colors.green,
|
|
foregroundColor: Colors.white,
|
|
padding: const EdgeInsets.symmetric(
|
|
horizontal: 16,
|
|
vertical: 12,
|
|
),
|
|
shape: RoundedRectangleBorder(
|
|
borderRadius: BorderRadius.circular(8),
|
|
),
|
|
),
|
|
),
|
|
],
|
|
),
|
|
],
|
|
),
|
|
),
|
|
),
|
|
);
|
|
}
|
|
|
|
Widget _buildConnectDisconnectButton() {
|
|
final colorScheme = Theme.of(context).colorScheme;
|
|
return Padding(
|
|
padding: const EdgeInsets.symmetric(
|
|
horizontal: 16.0,
|
|
vertical: 8.0,
|
|
),
|
|
child: Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
// AutoConnect toggle
|
|
Card(
|
|
elevation: 1,
|
|
shape: RoundedRectangleBorder(
|
|
borderRadius: BorderRadius.circular(12),
|
|
),
|
|
color: colorScheme.surfaceContainerHighest,
|
|
child: Padding(
|
|
padding: const EdgeInsets.symmetric(
|
|
horizontal: 12,
|
|
vertical: 8,
|
|
),
|
|
child: Row(
|
|
children: [
|
|
Icon(
|
|
Icons.autorenew,
|
|
size: 18,
|
|
color: colorScheme.primary,
|
|
),
|
|
const SizedBox(width: 8),
|
|
Expanded(
|
|
child: Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
Text(
|
|
'Auto Reconnect',
|
|
style: TextStyle(
|
|
fontSize: 13,
|
|
fontWeight: FontWeight.w600,
|
|
color: colorScheme.onSurface,
|
|
),
|
|
),
|
|
Text(
|
|
'Automatically reconnect when device becomes available',
|
|
style: TextStyle(
|
|
fontSize: 11,
|
|
color: colorScheme.onSurface.withValues(alpha: 0.7),
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
Switch(
|
|
value: _autoConnect,
|
|
onChanged: (value) async {
|
|
// If toggling off auto-connect, disconnect to prevent unwanted reconnections
|
|
if (!value && _autoConnect) {
|
|
if (isConnected) {
|
|
// Device is connected, disconnect it to prevent auto-reconnect
|
|
await _executeWithLoading(
|
|
() async {
|
|
await bleDevice.disconnect();
|
|
_addLog("DisconnectResult",
|
|
"Disconnected to disable auto-reconnect");
|
|
},
|
|
onError: (error) {
|
|
_addLog('DisconnectError', error);
|
|
},
|
|
);
|
|
} else {
|
|
// Device is already disconnected, but call disconnect() anyway
|
|
// to ensure cleanup and prevent any pending auto-reconnection attempts
|
|
await _executeWithLoading(
|
|
() async {
|
|
await bleDevice.disconnect();
|
|
_addLog("DisconnectResult",
|
|
"Cleanup performed to prevent auto-reconnect");
|
|
},
|
|
onError: (error) {
|
|
_addLog('DisconnectError', error);
|
|
},
|
|
);
|
|
}
|
|
}
|
|
setState(() {
|
|
_autoConnect = value;
|
|
});
|
|
},
|
|
materialTapTargetSize: MaterialTapTargetSize.shrinkWrap,
|
|
),
|
|
],
|
|
),
|
|
),
|
|
),
|
|
const SizedBox(height: 12),
|
|
// Connect/Disconnect button
|
|
SizedBox(
|
|
width: double.infinity,
|
|
child: ElevatedButton.icon(
|
|
onPressed: () async {
|
|
if (isConnected) {
|
|
await _executeWithLoading(
|
|
() async {
|
|
await bleDevice.disconnect();
|
|
_addLog("DisconnectResult", true);
|
|
},
|
|
onError: (error) {
|
|
_addLog('DisconnectError', error);
|
|
},
|
|
);
|
|
} else {
|
|
await _executeWithLoading(
|
|
() async {
|
|
await bleDevice.connect(autoConnect: _autoConnect);
|
|
_addLog(
|
|
"ConnectionResult",
|
|
"Connected${_autoConnect ? ' (Auto-reconnect enabled)' : ''}",
|
|
);
|
|
},
|
|
onError: (error) {
|
|
_addLog(
|
|
'ConnectError (${error.runtimeType})',
|
|
error,
|
|
);
|
|
},
|
|
);
|
|
}
|
|
},
|
|
icon: Icon(
|
|
isConnected
|
|
? Icons.bluetooth_disabled
|
|
: Icons.bluetooth_connected,
|
|
size: 20,
|
|
),
|
|
label: Text(isConnected ? 'Disconnect' : 'Connect'),
|
|
style: ElevatedButton.styleFrom(
|
|
backgroundColor:
|
|
isConnected ? colorScheme.error : colorScheme.primary,
|
|
foregroundColor:
|
|
isConnected ? colorScheme.onError : colorScheme.onPrimary,
|
|
padding: const EdgeInsets.symmetric(
|
|
vertical: 16,
|
|
),
|
|
shape: RoundedRectangleBorder(
|
|
borderRadius: BorderRadius.circular(12),
|
|
),
|
|
elevation: 2,
|
|
),
|
|
),
|
|
),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
|
|
Widget _buildDeviceInfo() {
|
|
final colorScheme = Theme.of(context).colorScheme;
|
|
return Padding(
|
|
padding: const EdgeInsets.symmetric(
|
|
horizontal: 16.0,
|
|
vertical: 2.0,
|
|
),
|
|
child: Card(
|
|
elevation: 2,
|
|
shape: RoundedRectangleBorder(
|
|
borderRadius: BorderRadius.circular(12),
|
|
),
|
|
child: Theme(
|
|
data: Theme.of(context).copyWith(
|
|
dividerColor: Colors.transparent,
|
|
),
|
|
child: ExpansionTile(
|
|
initiallyExpanded: _isDeviceInfoExpanded,
|
|
onExpansionChanged: (expanded) {
|
|
setState(() {
|
|
_isDeviceInfoExpanded = expanded;
|
|
});
|
|
},
|
|
tilePadding: const EdgeInsets.symmetric(
|
|
horizontal: 16,
|
|
vertical: 8,
|
|
),
|
|
childrenPadding: const EdgeInsets.only(
|
|
left: 16,
|
|
right: 16,
|
|
bottom: 16,
|
|
),
|
|
leading: Icon(
|
|
Icons.info_outline,
|
|
color: colorScheme.primary,
|
|
size: 24,
|
|
),
|
|
title: Text(
|
|
'Device Information',
|
|
style: TextStyle(
|
|
fontWeight: FontWeight.bold,
|
|
fontSize: 18,
|
|
color: colorScheme.onSurface,
|
|
),
|
|
),
|
|
subtitle: !_isDeviceInfoExpanded
|
|
? Padding(
|
|
padding: const EdgeInsets.only(top: 4),
|
|
child: Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
Text(
|
|
'Name: ${bleDevice.name ?? "Unknown"}',
|
|
style: TextStyle(
|
|
fontSize: 12,
|
|
color: colorScheme.onSurface.withValues(alpha: 0.7),
|
|
),
|
|
),
|
|
const SizedBox(height: 2),
|
|
Text(
|
|
'ID: ${bleDevice.deviceId}',
|
|
style: TextStyle(
|
|
fontSize: 11,
|
|
fontFamily: 'monospace',
|
|
color: colorScheme.onSurface.withValues(alpha: 0.6),
|
|
),
|
|
maxLines: 1,
|
|
overflow: TextOverflow.ellipsis,
|
|
),
|
|
],
|
|
),
|
|
)
|
|
: null,
|
|
expandedCrossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
_buildInfoRow(
|
|
context,
|
|
'Device ID',
|
|
bleDevice.deviceId,
|
|
Icons.fingerprint,
|
|
),
|
|
const SizedBox(height: 12),
|
|
_buildInfoRow(
|
|
context,
|
|
'Name',
|
|
bleDevice.name ?? "Unknown",
|
|
Icons.label,
|
|
),
|
|
// Manufacturer data
|
|
if (bleDevice.manufacturerDataList.isNotEmpty) ...[
|
|
const SizedBox(height: 16),
|
|
Row(
|
|
children: [
|
|
Icon(
|
|
Icons.memory,
|
|
size: 18,
|
|
color: colorScheme.secondary,
|
|
),
|
|
const SizedBox(width: 8),
|
|
Text(
|
|
'Manufacturer Data',
|
|
style: TextStyle(
|
|
fontWeight: FontWeight.bold,
|
|
fontSize: 14,
|
|
color: colorScheme.onSurface,
|
|
),
|
|
),
|
|
],
|
|
),
|
|
const SizedBox(height: 8),
|
|
...bleDevice.manufacturerDataList.map(
|
|
(data) => Padding(
|
|
padding: const EdgeInsets.only(
|
|
bottom: 8.0,
|
|
),
|
|
child: Container(
|
|
padding: const EdgeInsets.all(12),
|
|
decoration: BoxDecoration(
|
|
color: colorScheme.secondaryContainer,
|
|
borderRadius: BorderRadius.circular(8),
|
|
),
|
|
child: Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
Row(
|
|
children: [
|
|
Text(
|
|
'Company ID: ',
|
|
style: TextStyle(
|
|
fontSize: 12,
|
|
fontWeight: FontWeight.w600,
|
|
color: colorScheme.onSecondaryContainer,
|
|
),
|
|
),
|
|
Expanded(
|
|
child: SelectableText(
|
|
data.companyIdRadix16,
|
|
style: TextStyle(
|
|
fontSize: 12,
|
|
fontFamily: 'monospace',
|
|
color: colorScheme.onSecondaryContainer,
|
|
),
|
|
),
|
|
),
|
|
],
|
|
),
|
|
CompanyInfoWidget(
|
|
companyId: data.companyId,
|
|
colorScheme: colorScheme,
|
|
labelStyle: TextStyle(
|
|
fontSize: 12,
|
|
fontWeight: FontWeight.w600,
|
|
color: colorScheme.onSecondaryContainer,
|
|
),
|
|
nameStyle: TextStyle(
|
|
fontSize: 12,
|
|
fontWeight: FontWeight.w500,
|
|
color: colorScheme.onSecondaryContainer,
|
|
),
|
|
),
|
|
if (data.payloadRadix16.isNotEmpty) ...[
|
|
const SizedBox(height: 4),
|
|
Row(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
Text(
|
|
'Payload: ',
|
|
style: TextStyle(
|
|
fontSize: 11,
|
|
color: colorScheme.onSecondaryContainer
|
|
.withValues(alpha: 0.8),
|
|
),
|
|
),
|
|
Expanded(
|
|
child: SelectableText(
|
|
data.payloadRadix16,
|
|
style: TextStyle(
|
|
fontSize: 11,
|
|
fontFamily: 'monospace',
|
|
color: colorScheme.onSecondaryContainer
|
|
.withValues(alpha: 0.8),
|
|
),
|
|
),
|
|
),
|
|
],
|
|
),
|
|
],
|
|
],
|
|
),
|
|
),
|
|
),
|
|
),
|
|
],
|
|
// Advertised services
|
|
if (bleDevice.services.isNotEmpty) ...[
|
|
const SizedBox(height: 16),
|
|
Row(
|
|
children: [
|
|
Icon(
|
|
Icons.list,
|
|
size: 18,
|
|
color: colorScheme.tertiary,
|
|
),
|
|
const SizedBox(width: 8),
|
|
Text(
|
|
'Advertised Services',
|
|
style: TextStyle(
|
|
fontWeight: FontWeight.bold,
|
|
fontSize: 14,
|
|
color: colorScheme.onSurface,
|
|
),
|
|
),
|
|
],
|
|
),
|
|
const SizedBox(height: 8),
|
|
Wrap(
|
|
spacing: 6,
|
|
runSpacing: 6,
|
|
children: bleDevice.services
|
|
.map(
|
|
(service) => Container(
|
|
padding: const EdgeInsets.symmetric(
|
|
horizontal: 10,
|
|
vertical: 6,
|
|
),
|
|
decoration: BoxDecoration(
|
|
color: colorScheme.tertiaryContainer,
|
|
borderRadius: BorderRadius.circular(8),
|
|
),
|
|
child: SelectableText(
|
|
service,
|
|
style: TextStyle(
|
|
fontSize: 11,
|
|
fontFamily: 'monospace',
|
|
color: colorScheme.onTertiaryContainer,
|
|
fontWeight: FontWeight.w500,
|
|
),
|
|
),
|
|
),
|
|
)
|
|
.toList(),
|
|
),
|
|
],
|
|
],
|
|
),
|
|
),
|
|
),
|
|
);
|
|
}
|
|
|
|
String _formatAdTime(DateTime dt) {
|
|
return '${dt.hour.toString().padLeft(2, '0')}:'
|
|
'${dt.minute.toString().padLeft(2, '0')}:'
|
|
'${dt.second.toString().padLeft(2, '0')}';
|
|
}
|
|
|
|
static String _advertisementPayload(BleDevice device) {
|
|
if (device.manufacturerDataList.isNotEmpty) {
|
|
return device.manufacturerDataList.first.payloadRadix16;
|
|
}
|
|
if (device.serviceData.isNotEmpty) {
|
|
final entry = device.serviceData.entries.first;
|
|
return '0x${entry.value.map((b) => b.toRadixString(16).padLeft(2, '0')).join('').toUpperCase()}';
|
|
}
|
|
return '—';
|
|
}
|
|
|
|
Future<void> _copyReceivedAdvertisements() async {
|
|
if (_receivedAdvertisements.isEmpty) return;
|
|
final lines = _receivedAdvertisements.reversed.map((ad) {
|
|
final ts = ad.timestampDateTime;
|
|
final timeStr = ts != null ? _formatAdTime(ts) : '—';
|
|
final payload = _advertisementPayload(ad);
|
|
return '$timeStr RSSI: ${ad.rssi ?? "—"} $payload';
|
|
}).toList();
|
|
await Clipboard.setData(ClipboardData(text: lines.join('\n')));
|
|
if (mounted) {
|
|
_showSnackBar('All advertisements copied to clipboard');
|
|
}
|
|
}
|
|
|
|
Widget _buildReceivedAdvertisements() {
|
|
final colorScheme = Theme.of(context).colorScheme;
|
|
return Padding(
|
|
padding: const EdgeInsets.symmetric(
|
|
horizontal: 16.0,
|
|
vertical: 2.0,
|
|
),
|
|
child: Card(
|
|
elevation: 2,
|
|
shape: RoundedRectangleBorder(
|
|
borderRadius: BorderRadius.circular(12),
|
|
),
|
|
child: Theme(
|
|
data: Theme.of(context).copyWith(
|
|
dividerColor: Colors.transparent,
|
|
),
|
|
child: ExpansionTile(
|
|
initiallyExpanded: _isReceivedAdvertisementsExpanded,
|
|
onExpansionChanged: (expanded) {
|
|
setState(() {
|
|
_isReceivedAdvertisementsExpanded = expanded;
|
|
});
|
|
},
|
|
tilePadding: const EdgeInsets.symmetric(
|
|
horizontal: 16,
|
|
vertical: 8,
|
|
),
|
|
childrenPadding: const EdgeInsets.only(
|
|
left: 16,
|
|
right: 16,
|
|
bottom: 16,
|
|
),
|
|
title: ResultHeaderBar(
|
|
icon: Icons.ad_units_outlined,
|
|
title: 'Received advertisements',
|
|
count: _receivedAdvertisements.length,
|
|
onCopy: _receivedAdvertisements.isEmpty
|
|
? null
|
|
: _copyReceivedAdvertisements,
|
|
copyTooltip: 'Copy all advertisements',
|
|
onClear: _receivedAdvertisements.isEmpty
|
|
? null
|
|
: () {
|
|
setState(() {
|
|
_receivedAdvertisements.clear();
|
|
});
|
|
},
|
|
clearTooltip: 'Clear all advertisements',
|
|
titleFontSize: 18,
|
|
),
|
|
trailing: Icon(
|
|
_isReceivedAdvertisementsExpanded
|
|
? Icons.expand_less
|
|
: Icons.expand_more,
|
|
),
|
|
children: [
|
|
_receivedAdvertisements.isEmpty
|
|
? Padding(
|
|
padding: const EdgeInsets.symmetric(vertical: 16),
|
|
child: Text(
|
|
'No advertisements yet',
|
|
style: TextStyle(
|
|
fontSize: 13,
|
|
color: colorScheme.onSurface.withValues(alpha: 0.6),
|
|
),
|
|
),
|
|
)
|
|
: ConstrainedBox(
|
|
constraints: const BoxConstraints(maxHeight: 200),
|
|
child: ListView.separated(
|
|
shrinkWrap: true,
|
|
itemCount: _receivedAdvertisements.length,
|
|
separatorBuilder: (_, __) => const SizedBox(height: 6),
|
|
itemBuilder: (context, index) {
|
|
final ad = _receivedAdvertisements[
|
|
_receivedAdvertisements.length - 1 - index];
|
|
final ts = ad.timestampDateTime;
|
|
final timeStr = ts != null
|
|
? _formatAdTime(ts)
|
|
: '—';
|
|
final payload = _advertisementPayload(ad);
|
|
return Padding(
|
|
padding: const EdgeInsets.symmetric(
|
|
vertical: 4,
|
|
horizontal: 8,
|
|
),
|
|
child: Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
mainAxisSize: MainAxisSize.min,
|
|
children: [
|
|
Row(
|
|
children: [
|
|
Icon(
|
|
Icons.schedule,
|
|
size: 14,
|
|
color: colorScheme.onSurface
|
|
.withValues(alpha: 0.6),
|
|
),
|
|
const SizedBox(width: 8),
|
|
Text(
|
|
timeStr,
|
|
style: TextStyle(
|
|
fontSize: 12,
|
|
fontFamily: 'monospace',
|
|
color: colorScheme.onSurface
|
|
.withValues(alpha: 0.8),
|
|
),
|
|
),
|
|
const SizedBox(width: 12),
|
|
Text(
|
|
'RSSI: ${ad.rssi ?? "—"}',
|
|
style: TextStyle(
|
|
fontSize: 12,
|
|
color: colorScheme.onSurface
|
|
.withValues(alpha: 0.8),
|
|
),
|
|
),
|
|
],
|
|
),
|
|
const SizedBox(height: 4),
|
|
Text(
|
|
payload,
|
|
style: TextStyle(
|
|
fontSize: 11,
|
|
fontFamily: 'monospace',
|
|
color: colorScheme.onSurface
|
|
.withValues(alpha: 0.7),
|
|
),
|
|
overflow: TextOverflow.ellipsis,
|
|
maxLines: 2,
|
|
),
|
|
],
|
|
),
|
|
);
|
|
},
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
),
|
|
);
|
|
}
|
|
|
|
Widget _buildDeviceActions() {
|
|
final colorScheme = Theme.of(context).colorScheme;
|
|
return Padding(
|
|
padding: const EdgeInsets.symmetric(
|
|
horizontal: 16.0,
|
|
vertical: 2.0,
|
|
),
|
|
child: Card(
|
|
elevation: 2,
|
|
shape: RoundedRectangleBorder(
|
|
borderRadius: BorderRadius.circular(12),
|
|
),
|
|
child: Theme(
|
|
data: Theme.of(context).copyWith(
|
|
dividerColor: Colors.transparent,
|
|
),
|
|
child: ExpansionTile(
|
|
initiallyExpanded: _isDeviceActionsExpanded,
|
|
onExpansionChanged: (expanded) {
|
|
setState(() {
|
|
_isDeviceActionsExpanded = expanded;
|
|
});
|
|
},
|
|
tilePadding: const EdgeInsets.symmetric(
|
|
horizontal: 16,
|
|
vertical: 8,
|
|
),
|
|
childrenPadding: const EdgeInsets.only(
|
|
left: 16,
|
|
right: 16,
|
|
bottom: 16,
|
|
),
|
|
leading: Icon(
|
|
Icons.devices,
|
|
color: colorScheme.primary,
|
|
size: 24,
|
|
),
|
|
title: Text(
|
|
'Device Actions',
|
|
style: TextStyle(
|
|
fontWeight: FontWeight.bold,
|
|
fontSize: 18,
|
|
color: colorScheme.onSurface,
|
|
),
|
|
),
|
|
expandedCrossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
SizedBox(
|
|
width: double.infinity,
|
|
child: Wrap(
|
|
spacing: 6,
|
|
runSpacing: 2,
|
|
children: [
|
|
ElevatedButton.icon(
|
|
onPressed: isConnected
|
|
? () async {
|
|
_discoverServices();
|
|
}
|
|
: null,
|
|
icon: const Icon(Icons.search),
|
|
label: const Text('Discover Services'),
|
|
style: ElevatedButton.styleFrom(
|
|
backgroundColor: colorScheme.primary,
|
|
foregroundColor: colorScheme.onPrimary,
|
|
padding: const EdgeInsets.symmetric(
|
|
horizontal: 16,
|
|
vertical: 12,
|
|
),
|
|
shape: RoundedRectangleBorder(
|
|
borderRadius: BorderRadius.circular(8),
|
|
),
|
|
),
|
|
),
|
|
OutlinedButton.icon(
|
|
onPressed: () async {
|
|
_addLog(
|
|
'ConnectionState',
|
|
await bleDevice.connectionState,
|
|
);
|
|
},
|
|
icon: const Icon(Icons.info_outline),
|
|
label: const Text('Connection State'),
|
|
style: OutlinedButton.styleFrom(
|
|
foregroundColor: colorScheme.onSurface,
|
|
padding: const EdgeInsets.symmetric(
|
|
horizontal: 16,
|
|
vertical: 12,
|
|
),
|
|
shape: RoundedRectangleBorder(
|
|
borderRadius: BorderRadius.circular(8),
|
|
),
|
|
),
|
|
),
|
|
OutlinedButton.icon(
|
|
onPressed: isConnected
|
|
? () async {
|
|
try {
|
|
int rssi = await bleDevice.readRssi();
|
|
_addLog('RSSI', '$rssi dBm');
|
|
} catch (e) {
|
|
_addLog('ReadRssiError (${e.runtimeType})', e);
|
|
}
|
|
}
|
|
: null,
|
|
icon: const Icon(Icons.signal_cellular_alt),
|
|
label: const Text('RSSI'),
|
|
style: OutlinedButton.styleFrom(
|
|
foregroundColor: colorScheme.onSurface,
|
|
padding: const EdgeInsets.symmetric(
|
|
horizontal: 16,
|
|
vertical: 12,
|
|
),
|
|
shape: RoundedRectangleBorder(
|
|
borderRadius: BorderRadius.circular(8),
|
|
),
|
|
),
|
|
),
|
|
if (BleCapabilities.supportsRequestMtuApi)
|
|
ElevatedButton.icon(
|
|
onPressed: isConnected
|
|
? () async {
|
|
await _executeWithLoading(
|
|
() async {
|
|
int mtu = await bleDevice.requestMtu(247);
|
|
_addLog('MTU', mtu);
|
|
},
|
|
onError: (error) {
|
|
_addLog('RequestMtuError', error);
|
|
},
|
|
);
|
|
}
|
|
: null,
|
|
icon: const Icon(Icons.speed),
|
|
label: const Text('MTU'),
|
|
style: ElevatedButton.styleFrom(
|
|
backgroundColor: colorScheme.secondary,
|
|
foregroundColor: colorScheme.onSecondary,
|
|
padding: const EdgeInsets.symmetric(
|
|
horizontal: 16,
|
|
vertical: 12,
|
|
),
|
|
shape: RoundedRectangleBorder(
|
|
borderRadius: BorderRadius.circular(8),
|
|
),
|
|
),
|
|
),
|
|
ElevatedButton.icon(
|
|
onPressed: BleCapabilities.supportsAllPairingKinds
|
|
? () async {
|
|
await _executeWithLoading(
|
|
() async {
|
|
await bleDevice.pair();
|
|
_addLog("Pairing Result", true);
|
|
},
|
|
onError: (error) {
|
|
_addLog('PairError', error);
|
|
},
|
|
);
|
|
}
|
|
: null,
|
|
icon: const Icon(Icons.link),
|
|
label: const Text('Pair'),
|
|
style: ElevatedButton.styleFrom(
|
|
backgroundColor: colorScheme.tertiary,
|
|
foregroundColor: colorScheme.onTertiary,
|
|
padding: const EdgeInsets.symmetric(
|
|
horizontal: 16,
|
|
vertical: 12,
|
|
),
|
|
shape: RoundedRectangleBorder(
|
|
borderRadius: BorderRadius.circular(8),
|
|
),
|
|
),
|
|
),
|
|
OutlinedButton.icon(
|
|
onPressed: () async {
|
|
await _executeWithLoading(
|
|
() async {
|
|
bool? isPaired = await bleDevice.isPaired();
|
|
_addLog('isPaired', isPaired);
|
|
},
|
|
onError: (error) {
|
|
_addLog('isPairedError', error);
|
|
},
|
|
);
|
|
},
|
|
icon: const Icon(Icons.check_circle),
|
|
label: const Text('Pairing State'),
|
|
style: OutlinedButton.styleFrom(
|
|
foregroundColor: colorScheme.onSurface,
|
|
padding: const EdgeInsets.symmetric(
|
|
horizontal: 16,
|
|
vertical: 12,
|
|
),
|
|
shape: RoundedRectangleBorder(
|
|
borderRadius: BorderRadius.circular(8),
|
|
),
|
|
),
|
|
),
|
|
if (BleCapabilities.hasSystemPairingApi)
|
|
OutlinedButton.icon(
|
|
onPressed: () async {
|
|
await _executeWithLoading(
|
|
() async {
|
|
await bleDevice.unpair();
|
|
},
|
|
onError: (error) {
|
|
_addLog('UnpairError', error);
|
|
},
|
|
);
|
|
},
|
|
icon: const Icon(Icons.link_off),
|
|
label: const Text('Unpair'),
|
|
style: OutlinedButton.styleFrom(
|
|
foregroundColor: colorScheme.error,
|
|
side: BorderSide(
|
|
color: colorScheme.error,
|
|
),
|
|
padding: const EdgeInsets.symmetric(
|
|
horizontal: 16,
|
|
vertical: 12,
|
|
),
|
|
shape: RoundedRectangleBorder(
|
|
borderRadius: BorderRadius.circular(8),
|
|
),
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
),
|
|
);
|
|
}
|
|
|
|
bool _hasSelectedCharacteristicProperty(
|
|
List<CharacteristicProperty> properties,
|
|
) {
|
|
return properties.any(
|
|
(property) =>
|
|
selectedCharacteristic?.properties.contains(property) ?? false,
|
|
);
|
|
}
|
|
|
|
Widget _buildInfoRow(
|
|
BuildContext context,
|
|
String label,
|
|
String value,
|
|
IconData icon,
|
|
) {
|
|
final colorScheme = Theme.of(context).colorScheme;
|
|
return Row(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
Icon(
|
|
icon,
|
|
size: 18,
|
|
color: colorScheme.onSurface.withValues(alpha: 0.6),
|
|
),
|
|
const SizedBox(width: 8),
|
|
Expanded(
|
|
child: Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
Text(
|
|
label,
|
|
style: TextStyle(
|
|
fontSize: 12,
|
|
color: colorScheme.onSurface.withValues(alpha: 0.6),
|
|
fontWeight: FontWeight.w500,
|
|
),
|
|
),
|
|
const SizedBox(height: 2),
|
|
SelectableText(
|
|
value,
|
|
style: TextStyle(
|
|
fontSize: 14,
|
|
color: colorScheme.onSurface,
|
|
fontFamily: label == 'Device ID' ? 'monospace' : null,
|
|
fontWeight: FontWeight.w500,
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
],
|
|
);
|
|
}
|
|
|
|
Widget _buildCharacteristicSelector() {
|
|
final colorScheme = Theme.of(context).colorScheme;
|
|
if (selectedCharacteristic == null) {
|
|
return Padding(
|
|
padding: const EdgeInsets.all(16.0),
|
|
child: InkWell(
|
|
onTap: () {
|
|
if (discoveredServices.isEmpty) {
|
|
_discoverServices();
|
|
} else {
|
|
_showCharacteristicSelector();
|
|
}
|
|
},
|
|
child: Container(
|
|
padding: const EdgeInsets.all(16),
|
|
decoration: BoxDecoration(
|
|
color: colorScheme.surfaceContainerHighest,
|
|
borderRadius: BorderRadius.circular(12),
|
|
),
|
|
child: Row(
|
|
children: [
|
|
Icon(
|
|
Icons.info_outline,
|
|
color: colorScheme.onSurface.withValues(alpha: 0.6),
|
|
),
|
|
const SizedBox(width: 12),
|
|
Expanded(
|
|
child: Text(
|
|
discoveredServices.isEmpty
|
|
? "Please discover services"
|
|
: "Please select a characteristic to read/write",
|
|
style: TextStyle(
|
|
fontStyle: FontStyle.italic,
|
|
color: colorScheme.onSurface.withValues(alpha: 0.7),
|
|
),
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
),
|
|
);
|
|
} else {
|
|
return Padding(
|
|
padding: const EdgeInsets.symmetric(
|
|
horizontal: 16.0,
|
|
vertical: 8.0,
|
|
),
|
|
child: _buildSelectedCharacteristicCard(),
|
|
);
|
|
}
|
|
}
|
|
|
|
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;
|
|
selectedCharacteristic = characteristic;
|
|
});
|
|
onSelect?.call(service, characteristic);
|
|
},
|
|
onFavoriteToggle: (serviceUuid) {
|
|
setState(() {
|
|
if (_favoriteServices.contains(
|
|
serviceUuid,
|
|
)) {
|
|
_favoriteServices.remove(serviceUuid);
|
|
} else {
|
|
_favoriteServices.add(serviceUuid);
|
|
}
|
|
});
|
|
_saveFavoriteServices();
|
|
},
|
|
);
|
|
}
|
|
|
|
/// 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(
|
|
borderRadius: BorderRadius.circular(12),
|
|
),
|
|
color: colorScheme.primaryContainer,
|
|
child: InkWell(
|
|
onTap: _showCharacteristicSelector,
|
|
borderRadius: BorderRadius.circular(12),
|
|
child: Padding(
|
|
padding: const EdgeInsets.all(12.0),
|
|
child: Column(
|
|
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: [
|
|
Icon(
|
|
Icons.settings,
|
|
size: 16,
|
|
color: colorScheme.onSurface.withValues(alpha: 0.6),
|
|
),
|
|
const SizedBox(width: 6),
|
|
Expanded(
|
|
child: Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
mainAxisSize: MainAxisSize.min,
|
|
children: [
|
|
Text(
|
|
'Characteristic',
|
|
style: TextStyle(
|
|
fontSize: 11,
|
|
color: colorScheme.onSurface.withValues(alpha: 0.6),
|
|
fontWeight: FontWeight.w500,
|
|
),
|
|
),
|
|
const SizedBox(height: 2),
|
|
Row(
|
|
children: [
|
|
Expanded(
|
|
child: InkWell(
|
|
onTap: () {
|
|
Clipboard.setData(ClipboardData(
|
|
text: selectedCharacteristic?.uuid ?? "",
|
|
));
|
|
_showSnackBar('Copied to clipboard');
|
|
},
|
|
child: Text(
|
|
selectedCharacteristic!.uuid,
|
|
style: TextStyle(
|
|
fontSize: 12,
|
|
fontFamily: 'monospace',
|
|
color: colorScheme.onSurface,
|
|
fontWeight: FontWeight.w500,
|
|
),
|
|
),
|
|
),
|
|
),
|
|
],
|
|
),
|
|
],
|
|
),
|
|
),
|
|
if (canNavigate) ...[
|
|
IconButton(
|
|
onPressed: _navigateToPreviousCharacteristic,
|
|
icon: const Icon(Icons.arrow_back_ios),
|
|
iconSize: 16,
|
|
tooltip: 'Previous Characteristic',
|
|
padding: EdgeInsets.zero,
|
|
constraints: const BoxConstraints(
|
|
minWidth: 28,
|
|
minHeight: 28,
|
|
),
|
|
color: colorScheme.primary,
|
|
),
|
|
IconButton(
|
|
onPressed: _navigateToNextCharacteristic,
|
|
icon: const Icon(Icons.arrow_forward_ios),
|
|
iconSize: 16,
|
|
tooltip: 'Next Characteristic',
|
|
padding: EdgeInsets.zero,
|
|
constraints: const BoxConstraints(
|
|
minWidth: 28,
|
|
minHeight: 28,
|
|
),
|
|
color: colorScheme.primary,
|
|
),
|
|
const SizedBox(width: 4),
|
|
],
|
|
Icon(
|
|
Icons.arrow_drop_down,
|
|
size: 18,
|
|
color: colorScheme.onSurface.withValues(alpha: 0.6),
|
|
),
|
|
],
|
|
),
|
|
const SizedBox(height: 8),
|
|
Row(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
Icon(
|
|
Icons.tune,
|
|
size: 16,
|
|
color: colorScheme.onSurface.withValues(alpha: 0.6),
|
|
),
|
|
const SizedBox(width: 6),
|
|
Expanded(
|
|
child: Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
mainAxisSize: MainAxisSize.min,
|
|
children: [
|
|
Text(
|
|
'Properties',
|
|
style: TextStyle(
|
|
fontSize: 11,
|
|
color: colorScheme.onSurface.withValues(alpha: 0.6),
|
|
fontWeight: FontWeight.w500,
|
|
),
|
|
),
|
|
const SizedBox(height: 4),
|
|
Wrap(
|
|
spacing: 4,
|
|
runSpacing: 4,
|
|
children:
|
|
selectedCharacteristic!.properties.map((prop) {
|
|
return Container(
|
|
padding: const EdgeInsets.symmetric(
|
|
horizontal: 6,
|
|
vertical: 2,
|
|
),
|
|
decoration: BoxDecoration(
|
|
color: colorScheme.secondaryContainer,
|
|
borderRadius: BorderRadius.circular(4),
|
|
),
|
|
child: Text(
|
|
prop.name,
|
|
style: TextStyle(
|
|
fontSize: 10,
|
|
color: colorScheme.onSecondaryContainer,
|
|
fontWeight: FontWeight.w600,
|
|
),
|
|
),
|
|
);
|
|
}).toList(),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
],
|
|
),
|
|
],
|
|
),
|
|
),
|
|
),
|
|
);
|
|
}
|
|
|
|
Widget _buildResultWidget({
|
|
required bool scrollable,
|
|
}) {
|
|
return ResultWidget(
|
|
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) {
|
|
_logs.removeAt(index);
|
|
} else {
|
|
_logs.clear();
|
|
}
|
|
});
|
|
},
|
|
);
|
|
}
|
|
}
|