Improve example app UI (#193)
* Improve example app ui * Store favorite services in local storage * Use Text instead of SelectableText * Improve colors * Fix flutter analyzer issues * Revert example app minimum sdk requirement * Implement hasPermission api and improve ui flow * More ui improvements * Improve ScannedItem ui * Improve control ui * Change color theme to blue * Minor improvements wip * improve responsive ui and flow * More improvements * More improvements * Update Mac podfile * Fix home page scroll view * Update app and package name * Revert changelog * Resolve Ai comments --------- Co-authored-by: Foti Dim <foti@navideck.com>
This commit is contained in:
@@ -5,6 +5,7 @@ import 'package:universal_ble/universal_ble.dart';
|
||||
|
||||
/// Mock implementation of [UniversalBlePlatform] for testing
|
||||
class MockUniversalBle extends UniversalBlePlatform {
|
||||
final Map<String, BleConnectionState> _connectionStateMap = {};
|
||||
final _mockBleDevice = BleDevice(
|
||||
name: 'MockDevice',
|
||||
deviceId: 'MockDeviceId',
|
||||
@@ -15,12 +16,20 @@ class MockUniversalBle extends UniversalBlePlatform {
|
||||
Uint8List _serviceValue = utf8.encode('Result');
|
||||
bool _isScanning = false;
|
||||
|
||||
final BleService _mockService = BleService('180', [
|
||||
BleCharacteristic('180A', [
|
||||
CharacteristicProperty.read,
|
||||
CharacteristicProperty.write,
|
||||
CharacteristicProperty.notify,
|
||||
], []),
|
||||
final BleService _mockService = BleService('180a', [
|
||||
BleCharacteristic.withMetaData(
|
||||
deviceId: 'MockDeviceId',
|
||||
serviceId: '180a',
|
||||
uuid: '220a',
|
||||
properties: [
|
||||
CharacteristicProperty.read,
|
||||
CharacteristicProperty.write,
|
||||
CharacteristicProperty.notify,
|
||||
],
|
||||
descriptors: [
|
||||
BleDescriptor('220b'),
|
||||
],
|
||||
),
|
||||
]);
|
||||
|
||||
@override
|
||||
@@ -45,11 +54,13 @@ class MockUniversalBle extends UniversalBlePlatform {
|
||||
@override
|
||||
Future<void> connect(String deviceId, {Duration? connectionTimeout}) async {
|
||||
updateConnection(deviceId, true);
|
||||
_connectionStateMap[deviceId] = BleConnectionState.connected;
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> disconnect(String deviceId) async {
|
||||
updateConnection(deviceId, false);
|
||||
_connectionStateMap[deviceId] = BleConnectionState.disconnected;
|
||||
}
|
||||
|
||||
@override
|
||||
@@ -71,7 +82,7 @@ class MockUniversalBle extends UniversalBlePlatform {
|
||||
|
||||
@override
|
||||
Future<List<BleDevice>> getSystemDevices(List<String>? withServices) async {
|
||||
return [];
|
||||
return [_mockBleDevice];
|
||||
}
|
||||
|
||||
@override
|
||||
@@ -124,17 +135,18 @@ class MockUniversalBle extends UniversalBlePlatform {
|
||||
}
|
||||
|
||||
@override
|
||||
Future<BleConnectionState> getConnectionState(String deviceId) {
|
||||
throw UnimplementedError();
|
||||
Future<BleConnectionState> getConnectionState(String deviceId) async {
|
||||
return _connectionStateMap[deviceId] ?? BleConnectionState.disconnected;
|
||||
}
|
||||
|
||||
@override
|
||||
Future<bool> disableBluetooth() {
|
||||
throw UnimplementedError();
|
||||
Future<bool> disableBluetooth() async {
|
||||
return true;
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> requestPermissions({bool withAndroidFineLocation = false}) {
|
||||
throw UnimplementedError();
|
||||
Future<void> requestPermissions(
|
||||
{bool withAndroidFineLocation = false}) async {
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
|
||||
class StorageService {
|
||||
StorageService._();
|
||||
static StorageService? _instance;
|
||||
static StorageService get instance => _instance ??= StorageService._();
|
||||
|
||||
late SharedPreferencesWithCache _preferences;
|
||||
|
||||
Future<void> init() async {
|
||||
_preferences = await SharedPreferencesWithCache.create(
|
||||
cacheOptions: const SharedPreferencesWithCacheOptions(),
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> setFavoriteServices(List<String> services) async {
|
||||
await _preferences.setStringList('favorite_services', services);
|
||||
}
|
||||
|
||||
List<String> getFavoriteServices() =>
|
||||
_preferences.getStringList('favorite_services') ?? [];
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
bool isSystemService(String uuid) {
|
||||
final normalized = uuid.toUpperCase().replaceAll('-', '');
|
||||
return normalized == '00001800' ||
|
||||
normalized == '00001801' ||
|
||||
normalized == '0000180A' ||
|
||||
normalized.startsWith('000018');
|
||||
}
|
||||
@@ -1,356 +0,0 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:universal_ble/universal_ble.dart';
|
||||
import 'package:universal_ble_example/data/mock_universal_ble.dart';
|
||||
import 'package:universal_ble_example/home/widgets/scan_filter_widget.dart';
|
||||
import 'package:universal_ble_example/home/widgets/scanned_devices_placeholder_widget.dart';
|
||||
import 'package:universal_ble_example/home/widgets/scanned_item_widget.dart';
|
||||
import 'package:universal_ble_example/peripheral_details/peripheral_detail_page.dart';
|
||||
import 'package:universal_ble_example/widgets/platform_button.dart';
|
||||
import 'package:universal_ble_example/widgets/responsive_buttons_grid.dart';
|
||||
|
||||
class MyApp extends StatefulWidget {
|
||||
const MyApp({super.key});
|
||||
|
||||
@override
|
||||
State createState() => _MyAppState();
|
||||
}
|
||||
|
||||
class _MyAppState extends State<MyApp> {
|
||||
final _bleDevices = <BleDevice>[];
|
||||
final _hiddenDevices = <BleDevice>[];
|
||||
bool _isScanning = false;
|
||||
QueueType _queueType = QueueType.global;
|
||||
TextEditingController servicesFilterController = TextEditingController();
|
||||
TextEditingController namePrefixController = TextEditingController();
|
||||
TextEditingController manufacturerDataController = TextEditingController();
|
||||
StreamSubscription<AvailabilityState>? _availabilityStreamSubscription;
|
||||
|
||||
bool get isTrackingAvailabilityState =>
|
||||
_availabilityStreamSubscription != null;
|
||||
AvailabilityState? bleAvailabilityState;
|
||||
ScanFilter? scanFilter;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
|
||||
/// Set mock instance for testing
|
||||
if (const bool.fromEnvironment('MOCK')) {
|
||||
UniversalBle.setInstance(MockUniversalBle());
|
||||
}
|
||||
|
||||
/// Setup queue and timeout
|
||||
UniversalBle.queueType = _queueType;
|
||||
UniversalBle.timeout = const Duration(seconds: 10);
|
||||
|
||||
UniversalBle.scanStream.listen((result) {
|
||||
// log(result.toString());
|
||||
// If device is already in hidden devices, skip
|
||||
if (_hiddenDevices.any((e) => e.deviceId == result.deviceId)) {
|
||||
// debugPrint("Skipping hidden device: ${result.deviceId}");
|
||||
return;
|
||||
}
|
||||
int index = _bleDevices.indexWhere((e) => e.deviceId == result.deviceId);
|
||||
if (index == -1) {
|
||||
_bleDevices.add(result);
|
||||
} else {
|
||||
if (result.name == null && _bleDevices[index].name != null) {
|
||||
result.name = _bleDevices[index].name;
|
||||
}
|
||||
_bleDevices[index] = result;
|
||||
}
|
||||
setState(() {});
|
||||
});
|
||||
|
||||
// UniversalBle.onQueueUpdate = (String id, int remainingItems) {
|
||||
// debugPrint("Queue: $id RemainingItems: $remainingItems");
|
||||
// };
|
||||
|
||||
UniversalBle.isScanning().then((value) {
|
||||
debugPrint("Is Scanning: $value");
|
||||
setState(() {
|
||||
_isScanning = value;
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
void trackAvailabilityState() {
|
||||
_availabilityStreamSubscription = UniversalBle.availabilityStream.listen(
|
||||
(state) {
|
||||
setState(() {
|
||||
bleAvailabilityState = state;
|
||||
});
|
||||
},
|
||||
);
|
||||
setState(() {});
|
||||
}
|
||||
|
||||
Future<void> startScan() async {
|
||||
await UniversalBle.startScan(scanFilter: scanFilter);
|
||||
}
|
||||
|
||||
Future<void> _getSystemDevices() async {
|
||||
// For macOS and iOS, it is recommended to set a filter to get system devices
|
||||
if ((defaultTargetPlatform == TargetPlatform.macOS ||
|
||||
defaultTargetPlatform == TargetPlatform.iOS) &&
|
||||
(scanFilter?.withServices ?? []).isEmpty) {
|
||||
showSnackbar(
|
||||
"No services filter was set for getting system connected devices. Using default services...");
|
||||
}
|
||||
|
||||
List<BleDevice> devices = await UniversalBle.getSystemDevices(
|
||||
withServices: scanFilter?.withServices,
|
||||
);
|
||||
if (devices.isEmpty) {
|
||||
showSnackbar("No System Connected Devices Found");
|
||||
}
|
||||
setState(() {
|
||||
_bleDevices.clear();
|
||||
_bleDevices.addAll(devices);
|
||||
});
|
||||
}
|
||||
|
||||
void _showScanFilterBottomSheet() {
|
||||
showModalBottomSheet(
|
||||
isScrollControlled: true,
|
||||
context: context,
|
||||
builder: (context) {
|
||||
return ScanFilterWidget(
|
||||
servicesFilterController: servicesFilterController,
|
||||
namePrefixController: namePrefixController,
|
||||
manufacturerDataController: manufacturerDataController,
|
||||
onScanFilter: (ScanFilter? filter) {
|
||||
setState(() {
|
||||
scanFilter = filter;
|
||||
});
|
||||
},
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
void showSnackbar(String message) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(content: Text(message)),
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_availabilityStreamSubscription?.cancel();
|
||||
servicesFilterController.dispose();
|
||||
namePrefixController.dispose();
|
||||
manufacturerDataController.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Scaffold(
|
||||
appBar: AppBar(
|
||||
title: const Text('Universal BLE'),
|
||||
elevation: 4,
|
||||
actions: [
|
||||
if (_isScanning)
|
||||
const Padding(
|
||||
padding: EdgeInsets.all(8.0),
|
||||
child: SizedBox(
|
||||
width: 20,
|
||||
height: 20,
|
||||
child: CircularProgressIndicator.adaptive(
|
||||
strokeWidth: 2,
|
||||
)),
|
||||
),
|
||||
],
|
||||
),
|
||||
body: Column(
|
||||
children: [
|
||||
Padding(
|
||||
padding: const EdgeInsets.all(8.0),
|
||||
child: ResponsiveButtonsGrid(
|
||||
children: [
|
||||
PlatformButton(
|
||||
text: 'Start Scan',
|
||||
onPressed: () async {
|
||||
setState(() {
|
||||
_bleDevices.clear();
|
||||
_isScanning = true;
|
||||
});
|
||||
try {
|
||||
await startScan();
|
||||
} catch (e) {
|
||||
setState(() {
|
||||
_isScanning = false;
|
||||
});
|
||||
showSnackbar(e.toString());
|
||||
}
|
||||
},
|
||||
),
|
||||
PlatformButton(
|
||||
text: 'Stop Scan',
|
||||
onPressed: () async {
|
||||
await UniversalBle.stopScan();
|
||||
setState(() {
|
||||
_isScanning = false;
|
||||
});
|
||||
},
|
||||
),
|
||||
if (BleCapabilities.supportsBluetoothEnableApi)
|
||||
bleAvailabilityState != AvailabilityState.poweredOn
|
||||
? PlatformButton(
|
||||
text: 'Enable Bluetooth',
|
||||
onPressed: () async {
|
||||
bool isEnabled =
|
||||
await UniversalBle.enableBluetooth();
|
||||
showSnackbar("BluetoothEnabled: $isEnabled");
|
||||
},
|
||||
)
|
||||
: PlatformButton(
|
||||
text: 'Disable Bluetooth',
|
||||
onPressed: () async {
|
||||
bool isDisabled =
|
||||
await UniversalBle.disableBluetooth();
|
||||
showSnackbar("BluetoothDisabled: $isDisabled");
|
||||
},
|
||||
),
|
||||
if (BleCapabilities.requiresRuntimePermission) ...[
|
||||
PlatformButton(
|
||||
text: 'Has Permissions',
|
||||
onPressed: () async {
|
||||
try {
|
||||
bool hasPermissions = await UniversalBle.hasPermissions(
|
||||
withAndroidFineLocation: false,
|
||||
);
|
||||
showSnackbar("Has Permissions: $hasPermissions");
|
||||
} catch (e) {
|
||||
showSnackbar(e.toString());
|
||||
}
|
||||
},
|
||||
),
|
||||
PlatformButton(
|
||||
text: 'Request Permissions',
|
||||
onPressed: () async {
|
||||
try {
|
||||
await UniversalBle.requestPermissions(
|
||||
withAndroidFineLocation: false,
|
||||
);
|
||||
showSnackbar("Permissions granted");
|
||||
} catch (e) {
|
||||
showSnackbar(e.toString());
|
||||
}
|
||||
},
|
||||
),
|
||||
],
|
||||
if (!isTrackingAvailabilityState)
|
||||
PlatformButton(
|
||||
text: 'Track Availability State',
|
||||
onPressed: trackAvailabilityState,
|
||||
),
|
||||
if (BleCapabilities.supportsConnectedDevicesApi)
|
||||
PlatformButton(
|
||||
text: 'System Devices',
|
||||
onPressed: _getSystemDevices,
|
||||
),
|
||||
PlatformButton(
|
||||
text: 'Queue: ${_queueType.name}',
|
||||
onPressed: () {
|
||||
setState(() {
|
||||
_queueType = switch (_queueType) {
|
||||
QueueType.global => QueueType.perDevice,
|
||||
QueueType.perDevice => QueueType.none,
|
||||
QueueType.none => QueueType.global,
|
||||
};
|
||||
UniversalBle.queueType = _queueType;
|
||||
});
|
||||
},
|
||||
),
|
||||
PlatformButton(
|
||||
text: 'Scan Filters',
|
||||
onPressed: _showScanFilterBottomSheet,
|
||||
),
|
||||
if (_hiddenDevices.isNotEmpty)
|
||||
PlatformButton(
|
||||
text: 'Unhide ${_hiddenDevices.length} Devices',
|
||||
onPressed: () {
|
||||
setState(() {
|
||||
_hiddenDevices.clear();
|
||||
});
|
||||
},
|
||||
)
|
||||
else if (_bleDevices.isNotEmpty)
|
||||
Tooltip(
|
||||
message:
|
||||
'Hide already discovered devices. When you turn on a new device, it will be easier to spot.',
|
||||
child: PlatformButton(
|
||||
text: 'Hide Already Discovered Devices',
|
||||
onPressed: () {
|
||||
setState(() {
|
||||
_hiddenDevices.clear();
|
||||
_hiddenDevices.addAll(_bleDevices);
|
||||
_bleDevices.clear();
|
||||
});
|
||||
},
|
||||
),
|
||||
),
|
||||
if (_bleDevices.isNotEmpty)
|
||||
PlatformButton(
|
||||
text: 'Clear List',
|
||||
onPressed: () {
|
||||
setState(() {
|
||||
_bleDevices.clear();
|
||||
});
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
if (isTrackingAvailabilityState)
|
||||
Padding(
|
||||
padding: const EdgeInsets.all(8.0),
|
||||
child: Text(
|
||||
'Ble Availability : ${bleAvailabilityState?.name}',
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const Divider(color: Colors.blue),
|
||||
Expanded(
|
||||
child: _isScanning && _bleDevices.isEmpty
|
||||
? const Center(child: CircularProgressIndicator.adaptive())
|
||||
: !_isScanning && _bleDevices.isEmpty
|
||||
? const ScannedDevicesPlaceholderWidget()
|
||||
: ListView.separated(
|
||||
itemCount: _bleDevices.length,
|
||||
separatorBuilder: (context, index) => const Divider(),
|
||||
itemBuilder: (context, index) {
|
||||
BleDevice device =
|
||||
_bleDevices[_bleDevices.length - index - 1];
|
||||
return ScannedItemWidget(
|
||||
bleDevice: device,
|
||||
onTap: () {
|
||||
Navigator.push(
|
||||
context,
|
||||
MaterialPageRoute(
|
||||
builder: (_) => PeripheralDetailPage(device),
|
||||
),
|
||||
);
|
||||
UniversalBle.stopScan();
|
||||
setState(() {
|
||||
_isScanning = false;
|
||||
});
|
||||
},
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,242 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:universal_ble/universal_ble.dart';
|
||||
import 'package:universal_ble_example/home/scanner_screen.dart';
|
||||
|
||||
class PermissionScreen extends StatefulWidget {
|
||||
const PermissionScreen({super.key});
|
||||
|
||||
@override
|
||||
State<PermissionScreen> createState() => _PermissionScreenState();
|
||||
}
|
||||
|
||||
class _PermissionScreenState extends State<PermissionScreen>
|
||||
with WidgetsBindingObserver {
|
||||
final bool _withAndroidFineLocation = false;
|
||||
bool _isChecking = true;
|
||||
bool _hasPermissions = false;
|
||||
bool _isRequesting = false;
|
||||
String? _errorMessage;
|
||||
|
||||
void navigateToHome() {
|
||||
if (mounted) {
|
||||
Navigator.of(context).pushReplacement(
|
||||
MaterialPageRoute(builder: (_) => const ScannerScreen()),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
WidgetsBinding.instance.addObserver(this);
|
||||
_checkPermissions();
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
WidgetsBinding.instance.removeObserver(this);
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
void didChangeAppLifecycleState(AppLifecycleState state) {
|
||||
super.didChangeAppLifecycleState(state);
|
||||
// Re-check permissions when app resumes (user might have granted in settings)
|
||||
if (state == AppLifecycleState.resumed && !_hasPermissions) {
|
||||
_checkPermissions();
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _checkPermissions() async {
|
||||
setState(() {
|
||||
_isChecking = true;
|
||||
_errorMessage = null;
|
||||
});
|
||||
|
||||
try {
|
||||
// Check if permissions are required for this platform
|
||||
if (!BleCapabilities.requiresRuntimePermission) {
|
||||
// No permissions needed, go directly to home
|
||||
navigateToHome();
|
||||
return;
|
||||
}
|
||||
|
||||
// Check if we already have permissions
|
||||
final hasPermissions = await UniversalBle.hasPermissions(
|
||||
withAndroidFineLocation: _withAndroidFineLocation,
|
||||
);
|
||||
|
||||
setState(() {
|
||||
_isChecking = false;
|
||||
_hasPermissions = hasPermissions;
|
||||
});
|
||||
|
||||
if (hasPermissions && mounted) {
|
||||
// Permissions already granted, go to home
|
||||
navigateToHome();
|
||||
}
|
||||
} catch (e) {
|
||||
setState(() {
|
||||
_isChecking = false;
|
||||
_errorMessage = e.toString();
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _requestPermissions() async {
|
||||
setState(() {
|
||||
_isRequesting = true;
|
||||
_errorMessage = null;
|
||||
});
|
||||
|
||||
try {
|
||||
await UniversalBle.requestPermissions(
|
||||
withAndroidFineLocation: _withAndroidFineLocation,
|
||||
);
|
||||
|
||||
// Permissions granted, go to home
|
||||
if (mounted) {
|
||||
navigateToHome();
|
||||
}
|
||||
} catch (e) {
|
||||
setState(() {
|
||||
_isRequesting = false;
|
||||
_errorMessage = e.toString();
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
final colorScheme = theme.colorScheme;
|
||||
|
||||
return Scaffold(
|
||||
backgroundColor: colorScheme.surface,
|
||||
body: SafeArea(
|
||||
child: Center(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(24.0),
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
// Icon
|
||||
Container(
|
||||
padding: const EdgeInsets.all(32),
|
||||
decoration: BoxDecoration(
|
||||
color: colorScheme.primaryContainer.withValues(alpha: 0.3),
|
||||
shape: BoxShape.circle,
|
||||
),
|
||||
child: Icon(
|
||||
Icons.security,
|
||||
size: 80,
|
||||
color: colorScheme.primary,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 32),
|
||||
Text(
|
||||
'Bluetooth Permissions Required',
|
||||
style: TextStyle(
|
||||
fontSize: 24,
|
||||
fontWeight: FontWeight.bold,
|
||||
color: colorScheme.onSurface,
|
||||
),
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
Text(
|
||||
'This app needs Bluetooth permissions to scan and connect to nearby devices.',
|
||||
style: TextStyle(
|
||||
fontSize: 16,
|
||||
color: colorScheme.onSurface.withValues(alpha: 0.7),
|
||||
),
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
const SizedBox(height: 48),
|
||||
if (_isChecking)
|
||||
CircularProgressIndicator(
|
||||
valueColor: AlwaysStoppedAnimation<Color>(
|
||||
colorScheme.primary,
|
||||
),
|
||||
)
|
||||
else if (!_hasPermissions)
|
||||
Column(
|
||||
children: [
|
||||
ElevatedButton.icon(
|
||||
onPressed: _isRequesting ? null : _requestPermissions,
|
||||
icon: _isRequesting
|
||||
? SizedBox(
|
||||
width: 20,
|
||||
height: 20,
|
||||
child: CircularProgressIndicator(
|
||||
strokeWidth: 2,
|
||||
valueColor: AlwaysStoppedAnimation<Color>(
|
||||
colorScheme.onPrimary,
|
||||
),
|
||||
),
|
||||
)
|
||||
: const Icon(Icons.check_circle),
|
||||
label: Text(
|
||||
_isRequesting
|
||||
? 'Requesting Permissions...'
|
||||
: 'Grant Permissions',
|
||||
style: const TextStyle(fontSize: 16),
|
||||
),
|
||||
style: ElevatedButton.styleFrom(
|
||||
backgroundColor: colorScheme.primary,
|
||||
foregroundColor: colorScheme.onPrimary,
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 32,
|
||||
vertical: 16,
|
||||
),
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
),
|
||||
),
|
||||
if (_errorMessage != null) ...[
|
||||
const SizedBox(height: 16),
|
||||
Container(
|
||||
padding: const EdgeInsets.all(12),
|
||||
decoration: BoxDecoration(
|
||||
color: colorScheme.errorContainer,
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
Icon(
|
||||
Icons.error_outline,
|
||||
color: colorScheme.error,
|
||||
size: 20,
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Expanded(
|
||||
child: Text(
|
||||
_errorMessage!,
|
||||
style: TextStyle(
|
||||
color: colorScheme.onErrorContainer,
|
||||
fontSize: 12,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
TextButton(
|
||||
onPressed: _requestPermissions,
|
||||
child: const Text('Try Again'),
|
||||
),
|
||||
],
|
||||
],
|
||||
)
|
||||
else
|
||||
const SizedBox.shrink(),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,655 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:universal_ble/universal_ble.dart';
|
||||
import 'package:universal_ble_example/data/mock_universal_ble.dart';
|
||||
import 'package:universal_ble_example/home/widgets/ble_availability_icon.dart';
|
||||
import 'package:universal_ble_example/home/widgets/drawer.dart';
|
||||
import 'package:universal_ble_example/home/widgets/queue_selector_widget.dart';
|
||||
import 'package:universal_ble_example/home/widgets/scan_filter_widget.dart';
|
||||
import 'package:universal_ble_example/home/widgets/scanned_devices_placeholder_widget.dart';
|
||||
import 'package:universal_ble_example/home/widgets/scanned_item_widget.dart';
|
||||
import 'package:universal_ble_example/peripheral_details/peripheral_detail_page.dart';
|
||||
|
||||
class ScannerScreen extends StatefulWidget {
|
||||
const ScannerScreen({super.key});
|
||||
|
||||
@override
|
||||
State createState() => _ScannerScreenState();
|
||||
}
|
||||
|
||||
class _ScannerScreenState extends State<ScannerScreen> {
|
||||
final _bleDevices = <BleDevice>[];
|
||||
final _hiddenDevices = <BleDevice>[];
|
||||
bool _isScanning = false;
|
||||
QueueType _queueType = QueueType.global;
|
||||
TextEditingController servicesFilterController = TextEditingController();
|
||||
TextEditingController namePrefixController = TextEditingController();
|
||||
TextEditingController manufacturerDataController = TextEditingController();
|
||||
final TextEditingController _searchFilterController = TextEditingController();
|
||||
final TextEditingController _webServicesController = TextEditingController();
|
||||
StreamSubscription<BleDevice>? _scanSubscription;
|
||||
|
||||
AvailabilityState? bleAvailabilityState;
|
||||
ScanFilter? scanFilter;
|
||||
final Map<String, bool> _isExpanded = {};
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
if (const bool.fromEnvironment('MOCK')) {
|
||||
UniversalBle.setInstance(MockUniversalBle());
|
||||
}
|
||||
UniversalBle.queueType = _queueType;
|
||||
UniversalBle.timeout = const Duration(seconds: 10);
|
||||
|
||||
_scanSubscription = UniversalBle.scanStream.listen(_handleScanResult);
|
||||
|
||||
UniversalBle.isScanning().then(
|
||||
(isScanning) => setState(() => _isScanning = isScanning),
|
||||
);
|
||||
}
|
||||
|
||||
void _handleScanResult(BleDevice result) {
|
||||
if (_hiddenDevices.any((e) => e.deviceId == result.deviceId)) {
|
||||
return;
|
||||
}
|
||||
int index = _bleDevices.indexWhere((e) => e.deviceId == result.deviceId);
|
||||
if (index == -1) {
|
||||
_bleDevices.add(result);
|
||||
} else {
|
||||
if (result.name == null && _bleDevices[index].name != null) {
|
||||
result.name = _bleDevices[index].name;
|
||||
}
|
||||
_bleDevices[index] = result;
|
||||
}
|
||||
setState(() {});
|
||||
}
|
||||
|
||||
Future<void> _startScan() async {
|
||||
setState(() {
|
||||
_bleDevices.clear();
|
||||
_isScanning = true;
|
||||
});
|
||||
try {
|
||||
PlatformConfig? platformConfig;
|
||||
if (kIsWeb && _webServicesController.text.isNotEmpty) {
|
||||
List<String> webServices = _webServicesController.text
|
||||
.split(',')
|
||||
.where((s) => s.trim().isNotEmpty)
|
||||
.map((s) {
|
||||
try {
|
||||
return BleUuidParser.string(s.trim());
|
||||
} catch (_) {
|
||||
return s.trim();
|
||||
}
|
||||
}).toList();
|
||||
platformConfig = PlatformConfig(
|
||||
web: WebOptions(optionalServices: webServices),
|
||||
);
|
||||
}
|
||||
await UniversalBle.startScan(
|
||||
scanFilter: scanFilter,
|
||||
platformConfig: platformConfig,
|
||||
);
|
||||
} catch (e) {
|
||||
setState(() {
|
||||
_isScanning = false;
|
||||
});
|
||||
showSnackbar(e.toString());
|
||||
}
|
||||
}
|
||||
|
||||
void _showScanFilterBottomSheet() {
|
||||
showModalBottomSheet(
|
||||
isScrollControlled: true,
|
||||
context: context,
|
||||
builder: (context) {
|
||||
return ScanFilterWidget(
|
||||
servicesFilterController: servicesFilterController,
|
||||
namePrefixController: namePrefixController,
|
||||
manufacturerDataController: manufacturerDataController,
|
||||
onScanFilter: (ScanFilter? filter) {
|
||||
setState(() {
|
||||
scanFilter = filter;
|
||||
});
|
||||
},
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
void showSnackbar(String message) {
|
||||
ScaffoldMessenger.of(
|
||||
context,
|
||||
).showSnackBar(SnackBar(content: Text(message)));
|
||||
}
|
||||
|
||||
List<BleDevice> get _filteredDevices {
|
||||
if (_searchFilterController.text.isEmpty) {
|
||||
return _bleDevices;
|
||||
}
|
||||
final filter = _searchFilterController.text.toLowerCase();
|
||||
return _bleDevices.where((device) {
|
||||
final name = device.name?.toLowerCase() ?? '';
|
||||
final deviceId = device.deviceId.toLowerCase();
|
||||
final services = device.services.join(' ').toLowerCase();
|
||||
return name.contains(filter) ||
|
||||
deviceId.contains(filter) ||
|
||||
services.contains(filter);
|
||||
}).toList();
|
||||
}
|
||||
|
||||
bool get _isBluetoothAvailable =>
|
||||
bleAvailabilityState == AvailabilityState.poweredOn;
|
||||
|
||||
@override
|
||||
void setState(VoidCallback fn) {
|
||||
if (mounted) super.setState(fn);
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
servicesFilterController.dispose();
|
||||
namePrefixController.dispose();
|
||||
manufacturerDataController.dispose();
|
||||
_searchFilterController.dispose();
|
||||
_webServicesController.dispose();
|
||||
|
||||
_scanSubscription?.cancel();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
void _showQueueBottomSheet() {
|
||||
showModalBottomSheet(
|
||||
context: context,
|
||||
builder: (context) => QueueSelectorWidget(
|
||||
queueType: _queueType,
|
||||
onQueueTypeChanged: (queueType) {
|
||||
setState(() {
|
||||
_queueType = queueType;
|
||||
UniversalBle.queueType = _queueType;
|
||||
});
|
||||
Navigator.pop(context);
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
final colorScheme = theme.colorScheme;
|
||||
|
||||
return Scaffold(
|
||||
backgroundColor: colorScheme.surface,
|
||||
drawer: const AppDrawer(),
|
||||
appBar: AppBar(
|
||||
title: Row(
|
||||
children: [
|
||||
BleAvailabilityIcon(onAvailabilityStateChanged: (state) {
|
||||
setState(() => bleAvailabilityState = state);
|
||||
}),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: const Text(
|
||||
'Scanner',
|
||||
style: TextStyle(
|
||||
fontWeight: FontWeight.bold,
|
||||
letterSpacing: 0.5,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
elevation: 0,
|
||||
backgroundColor: colorScheme.surface,
|
||||
leading: Builder(
|
||||
builder: (context) => IconButton(
|
||||
icon: const Icon(Icons.menu),
|
||||
onPressed: () => Scaffold.of(context).openDrawer(),
|
||||
),
|
||||
),
|
||||
actions: [
|
||||
IconButton(
|
||||
icon: Icon(
|
||||
_isScanning ? Icons.stop_circle : Icons.play_arrow,
|
||||
color: _isScanning ? colorScheme.error : colorScheme.primary,
|
||||
),
|
||||
tooltip: _isScanning ? 'Stop Scan' : 'Start Scan',
|
||||
onPressed: _isBluetoothAvailable
|
||||
? () async {
|
||||
if (_isScanning) {
|
||||
await UniversalBle.stopScan();
|
||||
setState(() {
|
||||
_isScanning = false;
|
||||
});
|
||||
} else {
|
||||
await _startScan();
|
||||
}
|
||||
}
|
||||
: null,
|
||||
),
|
||||
IconButton(
|
||||
icon: Icon(
|
||||
Icons.queue,
|
||||
color: colorScheme.onSurface,
|
||||
),
|
||||
tooltip: 'Queue Type',
|
||||
onPressed: _showQueueBottomSheet,
|
||||
),
|
||||
],
|
||||
),
|
||||
body: Column(
|
||||
children: [
|
||||
// Search filter
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
|
||||
child: Container(
|
||||
decoration: BoxDecoration(
|
||||
color: colorScheme.surfaceContainerHighest,
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
boxShadow: [
|
||||
BoxShadow(
|
||||
color: Colors.black.withValues(alpha: 0.05),
|
||||
blurRadius: 8,
|
||||
offset: const Offset(0, 2),
|
||||
),
|
||||
],
|
||||
),
|
||||
child: TextField(
|
||||
controller: _searchFilterController,
|
||||
decoration: InputDecoration(
|
||||
hintText: 'Search by name, ID, or services...',
|
||||
prefixIcon: Icon(
|
||||
Icons.search,
|
||||
color: colorScheme.primary,
|
||||
),
|
||||
suffixIcon: _searchFilterController.text.isNotEmpty
|
||||
? IconButton(
|
||||
icon: Icon(
|
||||
Icons.clear,
|
||||
color: colorScheme.onSurface.withValues(alpha: 0.6),
|
||||
),
|
||||
onPressed: () {
|
||||
_searchFilterController.clear();
|
||||
setState(() {});
|
||||
},
|
||||
)
|
||||
: null,
|
||||
border: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
borderSide: BorderSide.none,
|
||||
),
|
||||
filled: true,
|
||||
fillColor: colorScheme.surfaceContainerHighest,
|
||||
contentPadding: const EdgeInsets.symmetric(
|
||||
horizontal: 16,
|
||||
vertical: 16,
|
||||
),
|
||||
),
|
||||
onChanged: (_) => setState(() {}),
|
||||
),
|
||||
),
|
||||
),
|
||||
// Web Services input (only for web)
|
||||
if (kIsWeb)
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
|
||||
child: Container(
|
||||
decoration: BoxDecoration(
|
||||
color: colorScheme.surfaceContainerHighest,
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
border: Border.all(
|
||||
color: colorScheme.primary.withValues(alpha: 0.3),
|
||||
width: 1,
|
||||
),
|
||||
),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(16, 12, 16, 8),
|
||||
child: Row(
|
||||
children: [
|
||||
Icon(
|
||||
Icons.web,
|
||||
size: 18,
|
||||
color: colorScheme.primary,
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Text(
|
||||
'Ble Services',
|
||||
style: TextStyle(
|
||||
fontWeight: FontWeight.w600,
|
||||
fontSize: 13,
|
||||
color: colorScheme.onSurface,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(16, 0, 16, 12),
|
||||
child: TextFormField(
|
||||
controller: _webServicesController,
|
||||
maxLines: 2,
|
||||
style: const TextStyle(
|
||||
fontFamily: 'monospace', fontSize: 12),
|
||||
decoration: InputDecoration(
|
||||
hintText:
|
||||
'Enter service UUIDs for web (comma-separated)',
|
||||
helperText:
|
||||
'These services will be available to use after connection on Web',
|
||||
helperMaxLines: 2,
|
||||
helperStyle: TextStyle(
|
||||
fontSize: 11,
|
||||
color: colorScheme.onSurface.withValues(alpha: 0.6),
|
||||
),
|
||||
border: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
borderSide: BorderSide.none,
|
||||
),
|
||||
filled: true,
|
||||
fillColor: colorScheme.surface,
|
||||
contentPadding: const EdgeInsets.symmetric(
|
||||
horizontal: 12,
|
||||
vertical: 12,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
// Device count badge with hide/unhide
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
|
||||
child: Row(
|
||||
children: [
|
||||
Tooltip(
|
||||
message: _hiddenDevices.isNotEmpty
|
||||
? 'Show hidden devices'
|
||||
: _bleDevices.isNotEmpty
|
||||
? 'Hide already discovered devices. When you turn on a new device, it will be easier to spot.'
|
||||
: '',
|
||||
child: TextButton.icon(
|
||||
onPressed:
|
||||
_bleDevices.isNotEmpty || _hiddenDevices.isNotEmpty
|
||||
? () {
|
||||
if (_hiddenDevices.isNotEmpty) {
|
||||
// Unhide all devices
|
||||
setState(() {
|
||||
_hiddenDevices.clear();
|
||||
});
|
||||
} else if (_bleDevices.isNotEmpty) {
|
||||
// Hide all devices
|
||||
setState(() {
|
||||
_hiddenDevices.clear();
|
||||
_hiddenDevices.addAll(_bleDevices);
|
||||
_bleDevices.clear();
|
||||
});
|
||||
}
|
||||
}
|
||||
: null,
|
||||
label: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Text(
|
||||
'${_bleDevices.length} ${_hiddenDevices.isNotEmpty ? "/ ${_hiddenDevices.length}" : ""} device${_bleDevices.length == 1 ? '' : 's'}',
|
||||
style: TextStyle(
|
||||
color: colorScheme.onPrimaryContainer,
|
||||
fontSize: 12,
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 4),
|
||||
Icon(
|
||||
_hiddenDevices.isNotEmpty
|
||||
? Icons.visibility
|
||||
: Icons.visibility_off,
|
||||
size: 16,
|
||||
color: colorScheme.onPrimaryContainer,
|
||||
),
|
||||
],
|
||||
),
|
||||
style: TextButton.styleFrom(
|
||||
padding: EdgeInsets.symmetric(horizontal: 8),
|
||||
backgroundColor: colorScheme.primaryContainer,
|
||||
),
|
||||
),
|
||||
),
|
||||
const Spacer(),
|
||||
TextButton.icon(
|
||||
onPressed: _showScanFilterBottomSheet,
|
||||
icon: Icon(
|
||||
scanFilter != null
|
||||
? Icons.filter_list
|
||||
: Icons.filter_list_outlined,
|
||||
size: 18,
|
||||
color: scanFilter != null
|
||||
? colorScheme.primary
|
||||
: colorScheme.onSurface,
|
||||
),
|
||||
label: Text(
|
||||
'Filter',
|
||||
style: TextStyle(
|
||||
color: scanFilter != null
|
||||
? colorScheme.primary
|
||||
: colorScheme.onSurface,
|
||||
fontSize: 12,
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
style: TextButton.styleFrom(
|
||||
padding: EdgeInsets.symmetric(horizontal: 8),
|
||||
backgroundColor: scanFilter != null
|
||||
? colorScheme.primaryContainer
|
||||
: null,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 4),
|
||||
TextButton.icon(
|
||||
onPressed: () {
|
||||
setState(() {
|
||||
_bleDevices.clear();
|
||||
});
|
||||
},
|
||||
icon: Icon(
|
||||
Icons.clear_all,
|
||||
size: 18,
|
||||
color: colorScheme.error,
|
||||
),
|
||||
label: Text(
|
||||
'Clear',
|
||||
style: TextStyle(
|
||||
color: colorScheme.error,
|
||||
fontSize: 12,
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
style: TextButton.styleFrom(
|
||||
padding: EdgeInsets.symmetric(horizontal: 8),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
const Divider(),
|
||||
// Devices List
|
||||
Expanded(
|
||||
child: !_isBluetoothAvailable
|
||||
? Center(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(24.0),
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Container(
|
||||
padding: const EdgeInsets.all(32),
|
||||
decoration: BoxDecoration(
|
||||
color: colorScheme.errorContainer.withValues(
|
||||
alpha: 0.3,
|
||||
),
|
||||
shape: BoxShape.circle,
|
||||
),
|
||||
child: Icon(
|
||||
Icons.bluetooth_disabled,
|
||||
size: 80,
|
||||
color: colorScheme.error,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 32),
|
||||
Text(
|
||||
'Bluetooth is Turned Off',
|
||||
style: TextStyle(
|
||||
fontSize: 24,
|
||||
fontWeight: FontWeight.bold,
|
||||
color: colorScheme.onSurface,
|
||||
),
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
Text(
|
||||
'Please turn on Bluetooth to scan for devices',
|
||||
style: TextStyle(
|
||||
fontSize: 16,
|
||||
color: colorScheme.onSurface.withValues(
|
||||
alpha: 0.7,
|
||||
),
|
||||
),
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
if (BleCapabilities.supportsBluetoothEnableApi) ...[
|
||||
const SizedBox(height: 48),
|
||||
ElevatedButton.icon(
|
||||
onPressed: () async {
|
||||
try {
|
||||
bool isEnabled =
|
||||
await UniversalBle.enableBluetooth();
|
||||
if (!isEnabled) {
|
||||
showSnackbar(
|
||||
"Please enable Bluetooth in system settings",
|
||||
);
|
||||
}
|
||||
} catch (e) {
|
||||
showSnackbar(e.toString());
|
||||
}
|
||||
},
|
||||
icon: const Icon(Icons.bluetooth, size: 24),
|
||||
label: const Text(
|
||||
'Turn On Bluetooth',
|
||||
style: TextStyle(fontSize: 18),
|
||||
),
|
||||
style: ElevatedButton.styleFrom(
|
||||
backgroundColor: colorScheme.primary,
|
||||
foregroundColor: colorScheme.onPrimary,
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 32,
|
||||
vertical: 16,
|
||||
),
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
elevation: 2,
|
||||
),
|
||||
),
|
||||
]
|
||||
],
|
||||
),
|
||||
),
|
||||
)
|
||||
: _isScanning && _bleDevices.isEmpty
|
||||
? Center(
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
CircularProgressIndicator.adaptive(
|
||||
valueColor: AlwaysStoppedAnimation<Color>(
|
||||
colorScheme.primary,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
Text(
|
||||
'Scanning for devices...',
|
||||
style: TextStyle(
|
||||
color: colorScheme.onSurface
|
||||
.withValues(alpha: 0.6),
|
||||
fontSize: 14,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
)
|
||||
: !_isScanning && _bleDevices.isEmpty
|
||||
? ScannedDevicesPlaceholderWidget(onTap: _startScan)
|
||||
: _filteredDevices.isEmpty
|
||||
? Center(
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Icon(
|
||||
Icons.search_off,
|
||||
size: 64,
|
||||
color: colorScheme.onSurface
|
||||
.withValues(alpha: 0.3),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
Text(
|
||||
'No devices match your filter',
|
||||
style: TextStyle(
|
||||
color: colorScheme.onSurface
|
||||
.withValues(alpha: 0.6),
|
||||
fontSize: 16,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
)
|
||||
: ListView.separated(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 16,
|
||||
vertical: 8,
|
||||
),
|
||||
itemCount: _filteredDevices.length,
|
||||
separatorBuilder: (context, index) =>
|
||||
const SizedBox(height: 8),
|
||||
itemBuilder: (context, index) {
|
||||
BleDevice device = _filteredDevices[
|
||||
_filteredDevices.length - index - 1];
|
||||
return ScannedItemWidget(
|
||||
bleDevice: device,
|
||||
isExpanded:
|
||||
_isExpanded[device.deviceId] ?? false,
|
||||
onExpand: (isExpanded) {
|
||||
setState(() {
|
||||
_isExpanded[device.deviceId] =
|
||||
isExpanded;
|
||||
});
|
||||
},
|
||||
onTap: () {
|
||||
Navigator.push(
|
||||
context,
|
||||
MaterialPageRoute(
|
||||
builder: (_) =>
|
||||
PeripheralDetailPage(device),
|
||||
),
|
||||
);
|
||||
// Stop scan but keep results visible
|
||||
UniversalBle.stopScan();
|
||||
setState(() {
|
||||
_isScanning = false;
|
||||
});
|
||||
},
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,379 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:universal_ble/universal_ble.dart';
|
||||
import 'package:universal_ble_example/home/widgets/ble_availability_icon.dart';
|
||||
import 'package:universal_ble_example/home/widgets/drawer.dart';
|
||||
import 'package:universal_ble_example/home/widgets/scanned_item_widget.dart';
|
||||
import 'package:universal_ble_example/peripheral_details/peripheral_detail_page.dart';
|
||||
|
||||
class SystemDevicesScreen extends StatefulWidget {
|
||||
const SystemDevicesScreen({super.key});
|
||||
|
||||
@override
|
||||
State<SystemDevicesScreen> createState() => _SystemDevicesScreenState();
|
||||
}
|
||||
|
||||
class _SystemDevicesScreenState extends State<SystemDevicesScreen> {
|
||||
List<BleDevice> _systemDevices = [];
|
||||
bool _isLoading = false;
|
||||
AvailabilityState? bleAvailabilityState;
|
||||
List<String> withServices = [];
|
||||
final Map<String, bool> _isExpanded = {};
|
||||
final TextEditingController _servicesController = TextEditingController();
|
||||
|
||||
void _parseServices() {
|
||||
setState(() {
|
||||
withServices = [];
|
||||
if (_servicesController.text.isNotEmpty) {
|
||||
final services = _servicesController.text
|
||||
.split(',')
|
||||
.map((s) => s.trim())
|
||||
.where((s) => s.isNotEmpty)
|
||||
.toList();
|
||||
for (String service in services) {
|
||||
try {
|
||||
withServices.add(BleUuidParser.string(service));
|
||||
} catch (e) {
|
||||
_showSnackbar("Invalid Service UUID: $service");
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
Future<void> _getSystemDevices() async {
|
||||
_parseServices();
|
||||
|
||||
// For macOS and iOS, it is recommended to set a filter to get system devices
|
||||
if ((defaultTargetPlatform == TargetPlatform.macOS ||
|
||||
defaultTargetPlatform == TargetPlatform.iOS) &&
|
||||
withServices.isEmpty) {
|
||||
_showSnackbar(
|
||||
"No services filter was set for getting system connected devices. Using default services...",
|
||||
);
|
||||
}
|
||||
|
||||
setState(() {
|
||||
_isLoading = true;
|
||||
});
|
||||
|
||||
try {
|
||||
List<BleDevice> devices = await UniversalBle.getSystemDevices(
|
||||
withServices: withServices,
|
||||
);
|
||||
setState(() {
|
||||
_systemDevices = devices;
|
||||
_isLoading = false;
|
||||
});
|
||||
if (devices.isEmpty) {
|
||||
_showSnackbar("No System Connected Devices Found");
|
||||
}
|
||||
} catch (e) {
|
||||
setState(() {
|
||||
_isLoading = false;
|
||||
});
|
||||
_showSnackbar(e.toString());
|
||||
}
|
||||
}
|
||||
|
||||
void _showSnackbar(String message) {
|
||||
ScaffoldMessenger.of(context)
|
||||
.showSnackBar(SnackBar(content: Text(message)));
|
||||
}
|
||||
|
||||
bool get _isBluetoothAvailable =>
|
||||
bleAvailabilityState == AvailabilityState.poweredOn;
|
||||
|
||||
@override
|
||||
void setState(VoidCallback fn) {
|
||||
if (mounted) super.setState(fn);
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_servicesController.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
final colorScheme = theme.colorScheme;
|
||||
|
||||
return Scaffold(
|
||||
backgroundColor: colorScheme.surface,
|
||||
drawer: const AppDrawer(),
|
||||
appBar: AppBar(
|
||||
title: Row(
|
||||
children: [
|
||||
BleAvailabilityIcon(onAvailabilityStateChanged: (state) {
|
||||
setState(() => bleAvailabilityState = state);
|
||||
}),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: const Text(
|
||||
'System Devices',
|
||||
style: TextStyle(
|
||||
fontWeight: FontWeight.bold,
|
||||
letterSpacing: 0.5,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
elevation: 0,
|
||||
backgroundColor: colorScheme.surface,
|
||||
actions: [
|
||||
IconButton(
|
||||
icon: Icon(
|
||||
Icons.refresh,
|
||||
color: colorScheme.primary,
|
||||
),
|
||||
tooltip: 'Refresh',
|
||||
onPressed: _isBluetoothAvailable ? _getSystemDevices : null,
|
||||
),
|
||||
],
|
||||
),
|
||||
body: Column(
|
||||
children: [
|
||||
// Services input text box
|
||||
Padding(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: Card(
|
||||
elevation: 2,
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
Icon(
|
||||
Icons.apps,
|
||||
color: colorScheme.primary,
|
||||
size: 20,
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Text(
|
||||
'Service UUIDs (Optional)',
|
||||
style: TextStyle(
|
||||
fontWeight: FontWeight.bold,
|
||||
fontSize: 16,
|
||||
color: colorScheme.onSurface,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
'Enter service UUIDs separated by commas. Leave empty to use default services.\nMandatory on Apple platforms.',
|
||||
style: TextStyle(
|
||||
fontSize: 12,
|
||||
color: colorScheme.onSurface.withValues(alpha: 0.7),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
TextField(
|
||||
controller: _servicesController,
|
||||
maxLines: 8,
|
||||
minLines: 4,
|
||||
style: const TextStyle(
|
||||
fontFamily: 'monospace',
|
||||
fontSize: 12,
|
||||
),
|
||||
decoration: InputDecoration(
|
||||
hintText:
|
||||
'eg: 0000180f-0000-1000-8000-00805f9b34fb, 0000180a-0000-1000-8000-00805f9b34fb',
|
||||
border: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
filled: true,
|
||||
fillColor: colorScheme.surfaceContainerHighest,
|
||||
contentPadding: const EdgeInsets.all(12),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16),
|
||||
child: SizedBox(
|
||||
width: double.infinity,
|
||||
child: ElevatedButton.icon(
|
||||
onPressed: _isBluetoothAvailable && !_isLoading
|
||||
? _getSystemDevices
|
||||
: null,
|
||||
icon: _isLoading
|
||||
? SizedBox(
|
||||
width: 20,
|
||||
height: 20,
|
||||
child: CircularProgressIndicator(
|
||||
strokeWidth: 2,
|
||||
valueColor: AlwaysStoppedAnimation<Color>(
|
||||
colorScheme.onPrimary,
|
||||
),
|
||||
),
|
||||
)
|
||||
: const Icon(Icons.search),
|
||||
label: Text(_isLoading ? 'Loading...' : 'Get System Devices'),
|
||||
style: ElevatedButton.styleFrom(
|
||||
backgroundColor: colorScheme.primary,
|
||||
foregroundColor: colorScheme.onPrimary,
|
||||
padding: const EdgeInsets.symmetric(vertical: 16),
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
elevation: 2,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
if (_systemDevices.isNotEmpty)
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
|
||||
child: Row(
|
||||
children: [
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 12,
|
||||
vertical: 6,
|
||||
),
|
||||
decoration: BoxDecoration(
|
||||
color: colorScheme.primaryContainer,
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
child: Text(
|
||||
'${_systemDevices.length} device${_systemDevices.length == 1 ? '' : 's'}',
|
||||
style: TextStyle(
|
||||
color: colorScheme.onPrimaryContainer,
|
||||
fontSize: 12,
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
const Divider(),
|
||||
Expanded(
|
||||
child: !_isBluetoothAvailable
|
||||
? Center(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(24.0),
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Container(
|
||||
padding: const EdgeInsets.all(32),
|
||||
decoration: BoxDecoration(
|
||||
color: colorScheme.errorContainer.withValues(
|
||||
alpha: 0.3,
|
||||
),
|
||||
shape: BoxShape.circle,
|
||||
),
|
||||
child: Icon(
|
||||
Icons.bluetooth_disabled,
|
||||
size: 80,
|
||||
color: colorScheme.error,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 32),
|
||||
Text(
|
||||
'Bluetooth is Turned Off',
|
||||
style: TextStyle(
|
||||
fontSize: 24,
|
||||
fontWeight: FontWeight.bold,
|
||||
color: colorScheme.onSurface,
|
||||
),
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
Text(
|
||||
'Please turn on Bluetooth to get system devices',
|
||||
style: TextStyle(
|
||||
fontSize: 16,
|
||||
color: colorScheme.onSurface.withValues(
|
||||
alpha: 0.7,
|
||||
),
|
||||
),
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
)
|
||||
: _systemDevices.isEmpty
|
||||
? Center(
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Icon(
|
||||
Icons.devices_outlined,
|
||||
size: 64,
|
||||
color:
|
||||
colorScheme.onSurface.withValues(alpha: 0.3),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
Text(
|
||||
'No system devices found',
|
||||
style: TextStyle(
|
||||
color: colorScheme.onSurface
|
||||
.withValues(alpha: 0.6),
|
||||
fontSize: 16,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
'Click "Get System Devices" to refresh',
|
||||
style: TextStyle(
|
||||
color: colorScheme.onSurface
|
||||
.withValues(alpha: 0.5),
|
||||
fontSize: 14,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
)
|
||||
: ListView.separated(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 16,
|
||||
vertical: 8,
|
||||
),
|
||||
itemCount: _systemDevices.length,
|
||||
separatorBuilder: (context, index) =>
|
||||
const SizedBox(height: 8),
|
||||
itemBuilder: (context, index) {
|
||||
BleDevice device = _systemDevices[index];
|
||||
return ScannedItemWidget(
|
||||
bleDevice: device,
|
||||
isExpanded: _isExpanded[device.deviceId] ?? false,
|
||||
onExpand: (isExpanded) {
|
||||
setState(() {
|
||||
_isExpanded[device.deviceId] = isExpanded;
|
||||
});
|
||||
},
|
||||
onTap: () {
|
||||
Navigator.push(
|
||||
context,
|
||||
MaterialPageRoute(
|
||||
builder: (_) => PeripheralDetailPage(device),
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:universal_ble/universal_ble.dart';
|
||||
|
||||
class BleAvailabilityIcon extends StatefulWidget {
|
||||
final Function(AvailabilityState) onAvailabilityStateChanged;
|
||||
const BleAvailabilityIcon({
|
||||
super.key,
|
||||
required this.onAvailabilityStateChanged,
|
||||
});
|
||||
|
||||
@override
|
||||
State<BleAvailabilityIcon> createState() => _BleAvailabilityIconState();
|
||||
}
|
||||
|
||||
class _BleAvailabilityIconState extends State<BleAvailabilityIcon> {
|
||||
AvailabilityState? bleAvailabilityState;
|
||||
StreamSubscription<AvailabilityState>? _availabilitySubscription;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
UniversalBle.getBluetoothAvailabilityState()
|
||||
.then(_handleAvailabilityStateChanged);
|
||||
_availabilitySubscription =
|
||||
UniversalBle.availabilityStream.listen(_handleAvailabilityStateChanged);
|
||||
}
|
||||
|
||||
void _handleAvailabilityStateChanged(AvailabilityState state) {
|
||||
if (mounted) setState(() => bleAvailabilityState = state);
|
||||
widget.onAvailabilityStateChanged(state);
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_availabilitySubscription?.cancel();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
if (bleAvailabilityState == null) {
|
||||
return const SizedBox.shrink();
|
||||
}
|
||||
return switch (bleAvailabilityState!) {
|
||||
AvailabilityState.resetting => Icon(
|
||||
Icons.bluetooth_searching,
|
||||
color: Theme.of(context).colorScheme.onSurface.withValues(alpha: 0.6),
|
||||
),
|
||||
AvailabilityState.poweredOn => Icon(
|
||||
Icons.bluetooth_connected,
|
||||
color: Theme.of(context).colorScheme.primary,
|
||||
),
|
||||
AvailabilityState.poweredOff => Icon(
|
||||
Icons.bluetooth_disabled,
|
||||
color: Theme.of(context).colorScheme.error,
|
||||
),
|
||||
AvailabilityState.unauthorized => Icon(
|
||||
Icons.bluetooth_disabled,
|
||||
color: Theme.of(context).colorScheme.error,
|
||||
),
|
||||
AvailabilityState.unsupported => Icon(
|
||||
Icons.bluetooth_disabled,
|
||||
color: Theme.of(context).colorScheme.outline,
|
||||
),
|
||||
AvailabilityState.unknown => Icon(
|
||||
Icons.bluetooth_searching,
|
||||
color: Theme.of(context).colorScheme.onSurface.withValues(alpha: 0.6),
|
||||
),
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:universal_ble/universal_ble.dart';
|
||||
|
||||
class BleDeviceInfoWidget extends StatelessWidget {
|
||||
final BleDevice bleDevice;
|
||||
const BleDeviceInfoWidget({super.key, required this.bleDevice});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16.0, vertical: 8.0),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
SelectableText('Device ID: ${bleDevice.deviceId}'),
|
||||
SelectableText('Name: ${bleDevice.name ?? 'N/A'}'),
|
||||
SelectableText('RSSI: ${bleDevice.rssi}'),
|
||||
SelectableText('Paired: ${bleDevice.paired}'),
|
||||
SelectableText(
|
||||
'Services: ${bleDevice.services.isNotEmpty ? bleDevice.services.join(', ') : 'N/A'}',
|
||||
),
|
||||
SelectableText(
|
||||
'Manufacturer Data: ${bleDevice.manufacturerDataList.isNotEmpty ? bleDevice.manufacturerDataList.map((e) => e.toString()).join(', ') : 'N/A'}',
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,109 @@
|
||||
import 'package:flutter/gestures.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:package_info_plus/package_info_plus.dart';
|
||||
import 'package:universal_ble/universal_ble.dart';
|
||||
import 'package:universal_ble_example/home/scanner_screen.dart';
|
||||
import 'package:universal_ble_example/home/system_devices_screen.dart';
|
||||
import 'package:url_launcher/url_launcher.dart';
|
||||
|
||||
class AppDrawer extends StatefulWidget {
|
||||
const AppDrawer({super.key});
|
||||
|
||||
@override
|
||||
State<AppDrawer> createState() => _AppDrawerState();
|
||||
}
|
||||
|
||||
class _AppDrawerState extends State<AppDrawer> {
|
||||
void _navigateToScreen(BuildContext context, Widget screen) {
|
||||
Navigator.pop(context);
|
||||
Navigator.pushReplacement(
|
||||
context,
|
||||
MaterialPageRoute(builder: (_) => screen),
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final colorScheme = Theme.of(context).colorScheme;
|
||||
return Drawer(
|
||||
child: Column(
|
||||
children: [
|
||||
SizedBox(
|
||||
height: 110,
|
||||
child: DrawerHeader(
|
||||
padding: const EdgeInsets.only(top: 0, left: 16),
|
||||
decoration: BoxDecoration(
|
||||
color: colorScheme.primary,
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
Image.asset('assets/icon.png', width: 40, height: 40),
|
||||
const SizedBox(width: 10),
|
||||
Expanded(
|
||||
child: Text(
|
||||
'Universal BLE',
|
||||
maxLines: 2,
|
||||
style:
|
||||
Theme.of(context).textTheme.headlineMedium?.copyWith(
|
||||
fontWeight: FontWeight.bold,
|
||||
color: colorScheme.onPrimary,
|
||||
height: 1,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
ListTile(
|
||||
leading: const Icon(Icons.search),
|
||||
title: const Text('Scanner'),
|
||||
onTap: () => _navigateToScreen(context, const ScannerScreen()),
|
||||
),
|
||||
if (BleCapabilities.supportsConnectedDevicesApi)
|
||||
ListTile(
|
||||
leading: const Icon(Icons.devices),
|
||||
title: const Text('System Devices'),
|
||||
onTap: () =>
|
||||
_navigateToScreen(context, const SystemDevicesScreen()),
|
||||
),
|
||||
const Divider(),
|
||||
FutureBuilder(
|
||||
future: PackageInfo.fromPlatform(),
|
||||
builder: (_, snapshot) => AboutListTile(
|
||||
icon: const Icon(Icons.info_outline),
|
||||
applicationIcon:
|
||||
Image.asset('assets/icon.png', width: 40, height: 40),
|
||||
applicationName: 'Universal BLE',
|
||||
applicationVersion:
|
||||
"${snapshot.data?.version} (${snapshot.data?.buildNumber})",
|
||||
applicationLegalese: '\u{a9} 2023 Navideck',
|
||||
aboutBoxChildren: [
|
||||
const SizedBox(height: 24),
|
||||
RichText(
|
||||
textAlign: TextAlign.justify,
|
||||
text: TextSpan(
|
||||
children: [
|
||||
TextSpan(
|
||||
text: 'Learn More',
|
||||
style: const TextStyle(
|
||||
color: Colors.blue,
|
||||
decoration: TextDecoration.underline,
|
||||
),
|
||||
recognizer: TapGestureRecognizer()
|
||||
..onTap = () {
|
||||
launchUrl(Uri.parse(
|
||||
"https://github.com/Navideck/universal_ble"));
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,157 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:universal_ble/universal_ble.dart';
|
||||
|
||||
class QueueSelectorWidget extends StatelessWidget {
|
||||
final QueueType queueType;
|
||||
final Function(QueueType) onQueueTypeChanged;
|
||||
const QueueSelectorWidget({
|
||||
super.key,
|
||||
required this.queueType,
|
||||
required this.onQueueTypeChanged,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final colorScheme = Theme.of(context).colorScheme;
|
||||
return Container(
|
||||
padding: const EdgeInsets.all(24),
|
||||
child: SingleChildScrollView(
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
Icon(
|
||||
Icons.queue,
|
||||
color: colorScheme.primary,
|
||||
size: 28,
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: Text(
|
||||
"Queue Type",
|
||||
style: Theme.of(context).textTheme.titleLarge?.copyWith(
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
),
|
||||
IconButton(
|
||||
icon: const Icon(Icons.close),
|
||||
onPressed: () => Navigator.pop(context),
|
||||
tooltip: 'Close',
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 2),
|
||||
Text(
|
||||
"Controls how BLE commands are executed.",
|
||||
style: Theme.of(context).textTheme.bodySmall?.copyWith(
|
||||
color: colorScheme.onSurface.withValues(alpha: 0.6),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
_buildQueueOption(
|
||||
context,
|
||||
QueueType.global,
|
||||
'Global',
|
||||
'All commands from all devices execute sequentially in a single queue',
|
||||
Icons.queue,
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
_buildQueueOption(
|
||||
context,
|
||||
QueueType.perDevice,
|
||||
'Per Device',
|
||||
'Commands for each device execute in separate queues',
|
||||
Icons.devices,
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
_buildQueueOption(
|
||||
context,
|
||||
QueueType.none,
|
||||
'None',
|
||||
'All commands execute in parallel without queuing',
|
||||
Icons.all_inclusive,
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildQueueOption(
|
||||
BuildContext context,
|
||||
QueueType value,
|
||||
String title,
|
||||
String description,
|
||||
IconData icon,
|
||||
) {
|
||||
final colorScheme = Theme.of(context).colorScheme;
|
||||
final isSelected = queueType == value;
|
||||
return InkWell(
|
||||
onTap: () => onQueueTypeChanged(value),
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
child: Container(
|
||||
padding: const EdgeInsets.all(6),
|
||||
decoration: BoxDecoration(
|
||||
color: isSelected
|
||||
? colorScheme.primaryContainer
|
||||
: colorScheme.surfaceContainerHighest,
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
border: Border.all(
|
||||
color: isSelected
|
||||
? colorScheme.primary
|
||||
: colorScheme.outline.withValues(alpha: 0.2),
|
||||
width: isSelected ? 2 : 1,
|
||||
),
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
Icon(
|
||||
icon,
|
||||
color: isSelected
|
||||
? colorScheme.onPrimaryContainer
|
||||
: colorScheme.onSurface,
|
||||
),
|
||||
const SizedBox(width: 16),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
title,
|
||||
style: TextStyle(
|
||||
fontWeight: FontWeight.bold,
|
||||
fontSize: 16,
|
||||
color: isSelected
|
||||
? colorScheme.onPrimaryContainer
|
||||
: colorScheme.onSurface,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
Text(
|
||||
description,
|
||||
style: TextStyle(
|
||||
fontSize: 12,
|
||||
color: isSelected
|
||||
? colorScheme.onPrimaryContainer
|
||||
.withValues(alpha: 0.7)
|
||||
: colorScheme.onSurface.withValues(alpha: 0.6),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
if (isSelected)
|
||||
Icon(
|
||||
Icons.check_circle,
|
||||
color: colorScheme.primary,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
class RssiSignalIndicator extends StatelessWidget {
|
||||
final int rssi;
|
||||
const RssiSignalIndicator({super.key, required this.rssi});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final bool isPositive = rssi >= 0;
|
||||
final Color barColor = isPositive ? Colors.green : Colors.red;
|
||||
// BLE RSSI ranges (in dBm):
|
||||
// -30 to 0: Excellent signal (4 bars)
|
||||
// -50 to -30: Good signal (3 bars)
|
||||
// -70 to -50: Fair signal (2 bars)
|
||||
// -90 to -70: Weak signal (1 bar)
|
||||
// Below -90: Very weak signal (0 bars)
|
||||
// Positive values: Extremely strong (4 bars)
|
||||
int bars;
|
||||
if (isPositive) {
|
||||
bars = 4; // Positive RSSI is extremely strong
|
||||
} else {
|
||||
if (rssi >= -30) {
|
||||
bars = 4; // Excellent
|
||||
} else if (rssi >= -50) {
|
||||
bars = 3; // Good
|
||||
} else if (rssi >= -70) {
|
||||
bars = 2; // Fair
|
||||
} else if (rssi >= -90) {
|
||||
bars = 1; // Weak
|
||||
} else {
|
||||
bars = 0; // Very weak
|
||||
}
|
||||
}
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.center,
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
SizedBox(
|
||||
width: 24,
|
||||
height: 16,
|
||||
child: Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.end,
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: List.generate(4, (index) {
|
||||
final barHeight = (index + 1) * 3.0 + 2.0;
|
||||
final isActive = index < bars;
|
||||
return Container(
|
||||
width: 3,
|
||||
height: barHeight,
|
||||
decoration: BoxDecoration(
|
||||
color: isActive ? barColor : Colors.grey.withAlpha(30),
|
||||
borderRadius: BorderRadius.circular(1.5),
|
||||
),
|
||||
);
|
||||
}),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
Text('$rssi'),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,5 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:universal_ble/universal_ble.dart';
|
||||
import 'package:universal_ble_example/widgets/platform_button.dart';
|
||||
|
||||
class ScanFilterWidget extends StatefulWidget {
|
||||
final void Function(ScanFilter? filter) onScanFilter;
|
||||
@@ -32,9 +31,13 @@ class _ScanFilterWidgetState extends State<ScanFilterWidget> {
|
||||
List<String> namePrefixes = [];
|
||||
List<ManufacturerDataFilter> manufacturerDataFilters = [];
|
||||
|
||||
// Parse Services
|
||||
// Parse Services - handle both comma and newline separated
|
||||
if (widget.servicesFilterController.text.isNotEmpty) {
|
||||
List<String> services = widget.servicesFilterController.text.split(',');
|
||||
List<String> services = widget.servicesFilterController.text
|
||||
.split(',')
|
||||
.map((e) => e.trim())
|
||||
.where((s) => s.isNotEmpty)
|
||||
.toList();
|
||||
for (String service in services) {
|
||||
try {
|
||||
serviceUUids.add(BleUuidParser.string(service.trim()));
|
||||
@@ -44,18 +47,34 @@ class _ScanFilterWidgetState extends State<ScanFilterWidget> {
|
||||
}
|
||||
}
|
||||
|
||||
// Parse Name Prefix
|
||||
// Parse Name Prefix - handle both comma and newline separated
|
||||
String namePrefix = widget.namePrefixController.text;
|
||||
if (namePrefix.isNotEmpty) {
|
||||
namePrefixes = namePrefix.split(',').map((e) => e.trim()).toList();
|
||||
}
|
||||
|
||||
// Parse Manufacturer Data
|
||||
// Parse Manufacturer Data - handle both comma and newline separated
|
||||
String manufacturerDataText = widget.manufacturerDataController.text;
|
||||
if (manufacturerDataText.isNotEmpty) {
|
||||
List<String> manufacturerData = manufacturerDataText.split(',');
|
||||
List<String> manufacturerData = manufacturerDataText
|
||||
.split(',')
|
||||
.map((e) => e.trim())
|
||||
.where((s) => s.isNotEmpty)
|
||||
.toList();
|
||||
for (String manufacturer in manufacturerData) {
|
||||
int? companyIdentifier = int.tryParse(manufacturer);
|
||||
String trimmed = manufacturer.trim();
|
||||
// Remove 0x prefix if present, otherwise parse as decimal or hex
|
||||
int? companyIdentifier;
|
||||
if (trimmed.toLowerCase().startsWith('0x')) {
|
||||
companyIdentifier = int.tryParse(trimmed.substring(2), radix: 16);
|
||||
} else {
|
||||
// Try parsing as hex first (if it contains letters), then decimal
|
||||
if (trimmed.contains(RegExp(r'[a-fA-F]'))) {
|
||||
companyIdentifier = int.tryParse(trimmed, radix: 16);
|
||||
} else {
|
||||
companyIdentifier = int.tryParse(trimmed);
|
||||
}
|
||||
}
|
||||
if (companyIdentifier == null) {
|
||||
throw Exception("Invalid Manufacturer Data $manufacturer");
|
||||
}
|
||||
@@ -98,74 +117,253 @@ class _ScanFilterWidgetState extends State<ScanFilterWidget> {
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Padding(
|
||||
padding: MediaQuery.of(context).viewInsets.copyWith(left: 20, right: 20),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
const SizedBox(height: 20),
|
||||
Center(
|
||||
child: Text(
|
||||
"Scan Filters",
|
||||
style: Theme.of(context).textTheme.titleLarge,
|
||||
final theme = Theme.of(context);
|
||||
final colorScheme = theme.colorScheme;
|
||||
|
||||
return Container(
|
||||
decoration: BoxDecoration(
|
||||
color: colorScheme.surface,
|
||||
borderRadius: const BorderRadius.vertical(top: Radius.circular(20)),
|
||||
),
|
||||
child: SafeArea(
|
||||
child: Padding(
|
||||
padding: EdgeInsets.only(
|
||||
left: 20,
|
||||
right: 20,
|
||||
top: 20,
|
||||
bottom: MediaQuery.of(context).viewInsets.bottom + 20,
|
||||
),
|
||||
child: SingleChildScrollView(
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
// Header
|
||||
Row(
|
||||
children: [
|
||||
Icon(
|
||||
Icons.filter_list,
|
||||
color: colorScheme.primary,
|
||||
size: 28,
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: Text(
|
||||
"Scan Filters",
|
||||
style: theme.textTheme.titleLarge?.copyWith(
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
),
|
||||
IconButton(
|
||||
icon: const Icon(Icons.close),
|
||||
onPressed: () => Navigator.pop(context),
|
||||
tooltip: 'Close',
|
||||
),
|
||||
],
|
||||
),
|
||||
Text(
|
||||
"Filter devices by name, services, or manufacturer data. Enter multiple values separated by commas.",
|
||||
style: theme.textTheme.bodySmall?.copyWith(
|
||||
color: colorScheme.onSurface.withValues(alpha: 0.6),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
if (error != null)
|
||||
Container(
|
||||
margin: const EdgeInsets.only(bottom: 16),
|
||||
padding: const EdgeInsets.all(12),
|
||||
decoration: BoxDecoration(
|
||||
color: colorScheme.errorContainer,
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
border: Border.all(
|
||||
color: colorScheme.error.withValues(alpha: 0.3),
|
||||
),
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
Icon(
|
||||
Icons.error_outline,
|
||||
color: colorScheme.error,
|
||||
size: 20,
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: Text(
|
||||
error!,
|
||||
style: TextStyle(
|
||||
color: colorScheme.onErrorContainer,
|
||||
fontSize: 13,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
|
||||
// Name Prefix Filter
|
||||
_buildFilterCard(
|
||||
context: context,
|
||||
title: "Name Prefixes",
|
||||
icon: Icons.text_fields,
|
||||
controller: widget.namePrefixController,
|
||||
hintText: "e.g. MyDevice Sensor",
|
||||
helperText: "Device names starting with these prefixes",
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
// Services Filter
|
||||
_buildFilterCard(
|
||||
context: context,
|
||||
title: "Service UUIDs",
|
||||
maxLines: 3,
|
||||
icon: Icons.apps,
|
||||
controller: widget.servicesFilterController,
|
||||
hintText: "e.g. 0000180f-0000-1000-8000-00805f9b34fb,180F",
|
||||
helperText: "Service UUIDs (16-bit, 32-bit, or 128-bit)",
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
// Manufacturer Data Filter
|
||||
_buildFilterCard(
|
||||
context: context,
|
||||
title: "Manufacturer Company IDs",
|
||||
icon: Icons.business,
|
||||
controller: widget.manufacturerDataController,
|
||||
hintText: "e.g. 76,0x004C",
|
||||
helperText: "Company identifiers in decimal or hex format",
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
// Action Buttons
|
||||
Row(
|
||||
children: [
|
||||
Expanded(
|
||||
flex: 2,
|
||||
child: OutlinedButton.icon(
|
||||
onPressed: clearFilter,
|
||||
icon: const Icon(Icons.clear_all),
|
||||
label: const Text('Clear All'),
|
||||
style: OutlinedButton.styleFrom(
|
||||
foregroundColor: colorScheme.onSurface,
|
||||
padding: const EdgeInsets.symmetric(vertical: 14),
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 5),
|
||||
Expanded(
|
||||
flex: 3,
|
||||
child: ElevatedButton.icon(
|
||||
onPressed: applyFilter,
|
||||
icon: const Icon(Icons.check),
|
||||
label: const Text('Apply Filters'),
|
||||
style: ElevatedButton.styleFrom(
|
||||
backgroundColor: colorScheme.primary,
|
||||
foregroundColor: colorScheme.onPrimary,
|
||||
padding: const EdgeInsets.symmetric(vertical: 14),
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
],
|
||||
),
|
||||
),
|
||||
const Text("Use comma to add multiple values"),
|
||||
const Divider(),
|
||||
const SizedBox(height: 10),
|
||||
TextFormField(
|
||||
controller: widget.namePrefixController,
|
||||
maxLines: 2,
|
||||
decoration: const InputDecoration(
|
||||
labelText: "Name Prefixes",
|
||||
border: OutlineInputBorder(),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildFilterCard({
|
||||
required BuildContext context,
|
||||
required String title,
|
||||
required IconData icon,
|
||||
required TextEditingController controller,
|
||||
required String hintText,
|
||||
required String helperText,
|
||||
int maxLines = 2,
|
||||
}) {
|
||||
final theme = Theme.of(context);
|
||||
final colorScheme = theme.colorScheme;
|
||||
|
||||
return Card(
|
||||
elevation: 0,
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
side: BorderSide(
|
||||
color: colorScheme.outline.withValues(alpha: 0.2),
|
||||
),
|
||||
),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(8),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
Icon(
|
||||
icon,
|
||||
size: 20,
|
||||
color: colorScheme.primary,
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Expanded(
|
||||
child: Text(
|
||||
title,
|
||||
style: theme.textTheme.titleSmall?.copyWith(
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 10),
|
||||
TextFormField(
|
||||
controller: widget.servicesFilterController,
|
||||
maxLines: 2,
|
||||
decoration: const InputDecoration(
|
||||
labelText: "Services",
|
||||
border: OutlineInputBorder(),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 10),
|
||||
TextFormField(
|
||||
controller: widget.manufacturerDataController,
|
||||
maxLines: 2,
|
||||
decoration: const InputDecoration(
|
||||
labelText: "Manufacturer Data Company IDs",
|
||||
border: OutlineInputBorder(),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 10),
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceAround,
|
||||
children: [
|
||||
Expanded(
|
||||
child: PlatformButton(
|
||||
text: 'Apply',
|
||||
onPressed: applyFilter,
|
||||
const SizedBox(height: 4),
|
||||
TextFormField(
|
||||
controller: controller,
|
||||
maxLines: maxLines,
|
||||
style: const TextStyle(fontFamily: 'monospace'),
|
||||
decoration: InputDecoration(
|
||||
hintText: hintText,
|
||||
helperText: helperText,
|
||||
helperMaxLines: 2,
|
||||
helperStyle: TextStyle(
|
||||
fontSize: 11,
|
||||
color: colorScheme.onSurface.withValues(alpha: 0.6),
|
||||
),
|
||||
border: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
borderSide: BorderSide(
|
||||
color: colorScheme.outline.withValues(alpha: 0.5),
|
||||
),
|
||||
),
|
||||
enabledBorder: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
borderSide: BorderSide(
|
||||
color: colorScheme.outline.withValues(alpha: 0.3),
|
||||
),
|
||||
),
|
||||
focusedBorder: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
borderSide: BorderSide(
|
||||
color: colorScheme.primary,
|
||||
width: 2,
|
||||
),
|
||||
),
|
||||
filled: true,
|
||||
fillColor:
|
||||
colorScheme.surfaceContainerHighest.withValues(alpha: 0.3),
|
||||
contentPadding: const EdgeInsets.symmetric(
|
||||
horizontal: 8,
|
||||
vertical: 8,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 10),
|
||||
Expanded(
|
||||
child: PlatformButton(
|
||||
text: 'Clear',
|
||||
onPressed: clearFilter,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 10),
|
||||
if (error != null)
|
||||
Text(
|
||||
error!,
|
||||
style: const TextStyle(color: Colors.red),
|
||||
),
|
||||
const SizedBox(height: 10),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,26 +1,58 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
class ScannedDevicesPlaceholderWidget extends StatelessWidget {
|
||||
const ScannedDevicesPlaceholderWidget({super.key});
|
||||
final Function() onTap;
|
||||
const ScannedDevicesPlaceholderWidget({super.key, required this.onTap});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return const Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Padding(
|
||||
padding: EdgeInsets.all(8.0),
|
||||
child: Icon(
|
||||
Icons.bluetooth,
|
||||
color: Colors.grey,
|
||||
size: 100,
|
||||
),
|
||||
final theme = Theme.of(context);
|
||||
final colorScheme = theme.colorScheme;
|
||||
|
||||
return Center(
|
||||
child: SingleChildScrollView(
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
InkWell(
|
||||
onTap: onTap,
|
||||
child: Container(
|
||||
padding: const EdgeInsets.all(32),
|
||||
decoration: BoxDecoration(
|
||||
color: colorScheme.primaryContainer.withValues(alpha: 0.3),
|
||||
shape: BoxShape.circle,
|
||||
),
|
||||
child: Icon(
|
||||
Icons.bluetooth_searching,
|
||||
size: 80,
|
||||
color: colorScheme.primary.withValues(alpha: 0.6),
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
Text(
|
||||
'No Devices Found',
|
||||
style: TextStyle(
|
||||
color: colorScheme.onSurface,
|
||||
fontSize: 24,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 48),
|
||||
child: Text(
|
||||
'Tap Scan button to discover nearby Bluetooth devices',
|
||||
textAlign: TextAlign.center,
|
||||
style: TextStyle(
|
||||
color: colorScheme.onSurface.withValues(alpha: 0.6),
|
||||
fontSize: 14,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
Text(
|
||||
'Scan For Devices',
|
||||
style: TextStyle(color: Colors.grey, fontSize: 22),
|
||||
)
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,50 +1,418 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:universal_ble/universal_ble.dart';
|
||||
import 'package:universal_ble_example/home/widgets/rssi_signal_indicator.dart';
|
||||
|
||||
class ScannedItemWidget extends StatelessWidget {
|
||||
final BleDevice bleDevice;
|
||||
final VoidCallback? onTap;
|
||||
const ScannedItemWidget({super.key, required this.bleDevice, this.onTap});
|
||||
final bool isExpanded;
|
||||
final Function(bool) onExpand;
|
||||
const ScannedItemWidget({
|
||||
super.key,
|
||||
required this.bleDevice,
|
||||
this.onTap,
|
||||
required this.isExpanded,
|
||||
required this.onExpand,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
final colorScheme = theme.colorScheme;
|
||||
|
||||
String? name = bleDevice.name;
|
||||
List<ManufacturerData> rawManufacturerData = bleDevice.manufacturerDataList;
|
||||
ManufacturerData? manufacturerData;
|
||||
if (rawManufacturerData.isNotEmpty) {
|
||||
manufacturerData = rawManufacturerData.first;
|
||||
}
|
||||
if (name == null || name.isEmpty) name = 'N/A';
|
||||
return Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8.0),
|
||||
child: Card(
|
||||
child: ListTile(
|
||||
title: Text(
|
||||
'$name (${bleDevice.rssi})',
|
||||
),
|
||||
subtitle: Column(
|
||||
if (name == null || name.isEmpty) name = 'Unknown Device';
|
||||
|
||||
return Card(
|
||||
elevation: 2,
|
||||
margin: EdgeInsets.zero,
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
),
|
||||
child: InkWell(
|
||||
onTap: onTap,
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(bleDevice.deviceId),
|
||||
Visibility(
|
||||
visible: manufacturerData != null,
|
||||
child: Text(manufacturerData.toString()),
|
||||
),
|
||||
if (bleDevice.timestampDateTime != null)
|
||||
Text("Last Seen: ${bleDevice.timestampDateTime}"),
|
||||
bleDevice.paired == true
|
||||
? const Text(
|
||||
"Paired",
|
||||
style: TextStyle(color: Colors.green),
|
||||
)
|
||||
: const Text(
|
||||
"Not Paired",
|
||||
style: TextStyle(color: Colors.red),
|
||||
Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
// Signal indicator
|
||||
Container(
|
||||
padding: const EdgeInsets.all(12),
|
||||
decoration: BoxDecoration(
|
||||
color:
|
||||
colorScheme.primaryContainer.withValues(alpha: 0.5),
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
child: RssiSignalIndicator(rssi: bleDevice.rssi ?? 0),
|
||||
),
|
||||
const SizedBox(width: 16),
|
||||
// Device info
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.center,
|
||||
children: [
|
||||
Expanded(
|
||||
child: Text(
|
||||
name,
|
||||
style: TextStyle(
|
||||
fontSize: 16,
|
||||
fontWeight: FontWeight.bold,
|
||||
color: colorScheme.onSurface,
|
||||
),
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
// Expand/Collapse button
|
||||
if (rawManufacturerData.isNotEmpty ||
|
||||
bleDevice.services.isNotEmpty)
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 4,
|
||||
vertical: 4,
|
||||
),
|
||||
decoration: BoxDecoration(
|
||||
color: colorScheme.primaryContainer
|
||||
.withValues(alpha: 0.3),
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
child: IconButton(
|
||||
icon: Icon(
|
||||
isExpanded
|
||||
? Icons.expand_less
|
||||
: Icons.expand_more,
|
||||
color: colorScheme.primary,
|
||||
size: 18,
|
||||
),
|
||||
onPressed: () {
|
||||
onExpand(!isExpanded);
|
||||
},
|
||||
tooltip: isExpanded ? 'Collapse' : 'Expand',
|
||||
padding: EdgeInsets.zero,
|
||||
constraints: const BoxConstraints(
|
||||
minWidth: 20,
|
||||
minHeight: 20,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 6),
|
||||
Row(
|
||||
children: [
|
||||
Icon(
|
||||
Icons.fingerprint,
|
||||
size: 12,
|
||||
color:
|
||||
colorScheme.onSurface.withValues(alpha: 0.6),
|
||||
),
|
||||
const SizedBox(width: 4),
|
||||
Expanded(
|
||||
child: Text(
|
||||
bleDevice.deviceId,
|
||||
style: TextStyle(
|
||||
fontSize: 12,
|
||||
color: colorScheme.onSurface
|
||||
.withValues(alpha: 0.7),
|
||||
fontFamily: 'monospace',
|
||||
),
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 6),
|
||||
Wrap(
|
||||
spacing: 4,
|
||||
runSpacing: 4,
|
||||
children: [
|
||||
// Pair status
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 8,
|
||||
vertical: 4,
|
||||
),
|
||||
decoration: BoxDecoration(
|
||||
color: bleDevice.paired == true
|
||||
? Colors.green.withValues(alpha: 0.2)
|
||||
: Colors.orange.withValues(alpha: 0.2),
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
child: Text(
|
||||
bleDevice.paired == true
|
||||
? 'Paired'
|
||||
: 'Unpaired',
|
||||
style: TextStyle(
|
||||
fontSize: 10,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: bleDevice.paired == true
|
||||
? Colors.green.shade700
|
||||
: Colors.orange.shade700,
|
||||
),
|
||||
),
|
||||
),
|
||||
// Manufacturer data (only in collapsed mode)
|
||||
if (!isExpanded) ...[
|
||||
...rawManufacturerData.take(2).map((data) {
|
||||
return Container(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 8,
|
||||
vertical: 4,
|
||||
),
|
||||
decoration: BoxDecoration(
|
||||
color: colorScheme.secondaryContainer,
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
child: Text(
|
||||
data.companyIdRadix16,
|
||||
style: TextStyle(
|
||||
fontSize: 10,
|
||||
color: colorScheme.onSecondaryContainer,
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
),
|
||||
);
|
||||
}),
|
||||
],
|
||||
// Services (only in collapsed mode)
|
||||
if (!isExpanded) ...[
|
||||
...bleDevice.services.take(3).map((service) {
|
||||
return Container(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 8,
|
||||
vertical: 4,
|
||||
),
|
||||
decoration: BoxDecoration(
|
||||
color: colorScheme.tertiaryContainer,
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
child: Text(
|
||||
service.length > 12
|
||||
? '${service.substring(0, 12)}...'
|
||||
: service,
|
||||
style: TextStyle(
|
||||
fontSize: 10,
|
||||
color: colorScheme.onTertiaryContainer,
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
),
|
||||
);
|
||||
}),
|
||||
if (bleDevice.services.length > 3)
|
||||
Text(
|
||||
'+${bleDevice.services.length - 3} more',
|
||||
style: TextStyle(
|
||||
fontSize: 10,
|
||||
color: colorScheme.onSurface
|
||||
.withValues(alpha: 0.5),
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
// Expanded details
|
||||
if (isExpanded) ...[
|
||||
const SizedBox(height: 16),
|
||||
const Divider(),
|
||||
const SizedBox(height: 12),
|
||||
if (rawManufacturerData.isNotEmpty) ...[
|
||||
Text(
|
||||
'Manufacturer Data',
|
||||
style: TextStyle(
|
||||
fontSize: 12,
|
||||
fontWeight: FontWeight.bold,
|
||||
color: colorScheme.onSurface,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
...rawManufacturerData.map((data) {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.only(bottom: 8),
|
||||
child: Container(
|
||||
padding: const EdgeInsets.all(12),
|
||||
decoration: BoxDecoration(
|
||||
color: colorScheme.secondaryContainer,
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment:
|
||||
CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
Text(
|
||||
'Company ID: ',
|
||||
style: TextStyle(
|
||||
fontSize: 11,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: colorScheme
|
||||
.onSecondaryContainer,
|
||||
),
|
||||
),
|
||||
Expanded(
|
||||
child: SelectableText(
|
||||
data.companyIdRadix16,
|
||||
style: TextStyle(
|
||||
fontSize: 11,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: colorScheme
|
||||
.onSecondaryContainer,
|
||||
fontFamily: 'monospace',
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
if (data.payloadRadix16.isNotEmpty) ...[
|
||||
const SizedBox(height: 4),
|
||||
Row(
|
||||
crossAxisAlignment:
|
||||
CrossAxisAlignment.start,
|
||||
children: [
|
||||
Expanded(
|
||||
child: SelectableText(
|
||||
data.payloadRadix16,
|
||||
style: TextStyle(
|
||||
fontSize: 10,
|
||||
color: colorScheme
|
||||
.onSecondaryContainer
|
||||
.withValues(alpha: 0.8),
|
||||
fontFamily: 'monospace',
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
IconButton(
|
||||
icon: Icon(
|
||||
Icons.copy,
|
||||
size: 16,
|
||||
color: colorScheme.onSecondaryContainer,
|
||||
),
|
||||
onPressed: () {
|
||||
final textToCopy = data
|
||||
.payloadRadix16.isNotEmpty
|
||||
? 'Company ID: ${data.companyIdRadix16}\nPayload: ${data.payloadRadix16}'
|
||||
: 'Company ID: ${data.companyIdRadix16}';
|
||||
Clipboard.setData(
|
||||
ClipboardData(text: textToCopy),
|
||||
);
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content:
|
||||
const Text('Copied to clipboard'),
|
||||
duration: const Duration(seconds: 1),
|
||||
behavior: SnackBarBehavior.floating,
|
||||
),
|
||||
);
|
||||
},
|
||||
tooltip: 'Copy',
|
||||
padding: EdgeInsets.zero,
|
||||
constraints: const BoxConstraints(
|
||||
minWidth: 24,
|
||||
minHeight: 24,
|
||||
),
|
||||
visualDensity: VisualDensity.compact,
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}),
|
||||
const SizedBox(height: 12),
|
||||
],
|
||||
if (bleDevice.services.isNotEmpty) ...[
|
||||
Text(
|
||||
'Advertised Services',
|
||||
style: TextStyle(
|
||||
fontSize: 12,
|
||||
fontWeight: FontWeight.bold,
|
||||
color: colorScheme.onSurface,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Wrap(
|
||||
spacing: 4,
|
||||
runSpacing: 4,
|
||||
children: bleDevice.services.map((service) {
|
||||
return Container(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 8,
|
||||
vertical: 4,
|
||||
),
|
||||
decoration: BoxDecoration(
|
||||
color: colorScheme.tertiaryContainer,
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
SelectableText(
|
||||
service,
|
||||
style: TextStyle(
|
||||
fontSize: 10,
|
||||
color: colorScheme.onTertiaryContainer,
|
||||
fontWeight: FontWeight.w500,
|
||||
fontFamily: 'monospace',
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 6),
|
||||
InkWell(
|
||||
onTap: () {
|
||||
Clipboard.setData(ClipboardData(text: service));
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: const Text('Copied to clipboard'),
|
||||
duration: const Duration(seconds: 1),
|
||||
behavior: SnackBarBehavior.floating,
|
||||
),
|
||||
);
|
||||
},
|
||||
child: Icon(
|
||||
Icons.copy,
|
||||
size: 14,
|
||||
color: colorScheme.onTertiaryContainer
|
||||
.withValues(alpha: 0.7),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}).toList(),
|
||||
),
|
||||
],
|
||||
],
|
||||
],
|
||||
),
|
||||
trailing: const Icon(Icons.arrow_forward_ios),
|
||||
onTap: onTap,
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
+38
-8
@@ -1,17 +1,47 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:universal_ble/universal_ble.dart';
|
||||
import 'package:universal_ble_example/home/home.dart';
|
||||
import 'package:universal_ble_example/data/storage_service.dart';
|
||||
import 'package:universal_ble_example/home/permission_screen.dart';
|
||||
import 'package:universal_ble_example/home/scanner_screen.dart';
|
||||
|
||||
void main() async {
|
||||
WidgetsFlutterBinding.ensureInitialized();
|
||||
await UniversalBle.setLogLevel(BleLogLevel.verbose);
|
||||
runApp(
|
||||
MaterialApp(
|
||||
await StorageService.instance.init();
|
||||
// await UniversalBle.setLogLevel(BleLogLevel.verbose);
|
||||
bool hasPermission = await UniversalBle.hasPermissions(
|
||||
withAndroidFineLocation: false,
|
||||
);
|
||||
|
||||
runApp(MyApp(
|
||||
hasPermission: hasPermission,
|
||||
));
|
||||
}
|
||||
|
||||
class MyApp extends StatelessWidget {
|
||||
final bool hasPermission;
|
||||
const MyApp({super.key, required this.hasPermission});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return MaterialApp(
|
||||
title: 'Universal BLE',
|
||||
debugShowCheckedModeBanner: false,
|
||||
darkTheme: ThemeData.dark(),
|
||||
theme: ThemeData(
|
||||
useMaterial3: true,
|
||||
colorScheme: ColorScheme.fromSeed(
|
||||
seedColor: Colors.blue,
|
||||
brightness: Brightness.light,
|
||||
),
|
||||
),
|
||||
darkTheme: ThemeData(
|
||||
useMaterial3: true,
|
||||
colorScheme: ColorScheme.fromSeed(
|
||||
seedColor: Colors.blue,
|
||||
brightness: Brightness.dark,
|
||||
),
|
||||
),
|
||||
themeMode: ThemeMode.system,
|
||||
home: const MyApp(),
|
||||
),
|
||||
);
|
||||
home: hasPermission ? ScannerScreen() : const PermissionScreen(),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -3,48 +3,196 @@ import 'package:flutter/material.dart';
|
||||
class ResultWidget extends StatelessWidget {
|
||||
final List<String> results;
|
||||
final bool scrollable;
|
||||
final ScrollController scrollController;
|
||||
final void Function(int? index) onClearTap;
|
||||
const ResultWidget({
|
||||
required this.results,
|
||||
required this.onClearTap,
|
||||
this.scrollable = false,
|
||||
required this.scrollController,
|
||||
super.key,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Column(
|
||||
children: [
|
||||
if (results.isNotEmpty)
|
||||
Padding(
|
||||
padding: const EdgeInsets.all(8.0),
|
||||
child: ListTile(
|
||||
tileColor: Theme.of(context).secondaryHeaderColor,
|
||||
title: const Text("Logs"),
|
||||
onTap: () {
|
||||
onClearTap(null);
|
||||
},
|
||||
trailing: const Icon(Icons.clear),
|
||||
final colorScheme = Theme.of(context).colorScheme;
|
||||
return Padding(
|
||||
padding: const EdgeInsets.all(16.0),
|
||||
child: Card(
|
||||
elevation: 2,
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
mainAxisSize: scrollable ? MainAxisSize.max : MainAxisSize.min,
|
||||
children: [
|
||||
_buildHeader(colorScheme),
|
||||
if (results.isEmpty)
|
||||
_buildEmptyState(colorScheme)
|
||||
else
|
||||
_buildLogsList(colorScheme),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildHeader(ColorScheme colorScheme) {
|
||||
return Container(
|
||||
padding: const EdgeInsets.all(16),
|
||||
decoration: BoxDecoration(
|
||||
color: colorScheme.surfaceContainerHighest,
|
||||
borderRadius: const BorderRadius.only(
|
||||
topLeft: Radius.circular(12),
|
||||
topRight: Radius.circular(12),
|
||||
),
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
Icon(
|
||||
Icons.history,
|
||||
color: colorScheme.primary,
|
||||
size: 20,
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Text(
|
||||
'Logs',
|
||||
style: TextStyle(
|
||||
fontWeight: FontWeight.bold,
|
||||
fontSize: 16,
|
||||
color: colorScheme.onSurface,
|
||||
),
|
||||
),
|
||||
ListView.separated(
|
||||
shrinkWrap: !scrollable,
|
||||
physics: scrollable ? null : const NeverScrollableScrollPhysics(),
|
||||
itemCount: results.length,
|
||||
reverse: true,
|
||||
itemBuilder: (BuildContext context, int index) {
|
||||
return InkWell(
|
||||
onTap: () => onClearTap(index),
|
||||
child: Padding(
|
||||
padding:
|
||||
const EdgeInsets.symmetric(horizontal: 11, vertical: 2),
|
||||
child: Text(results[index]),
|
||||
const Spacer(),
|
||||
if (results.isNotEmpty)
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 8,
|
||||
vertical: 4,
|
||||
),
|
||||
);
|
||||
},
|
||||
separatorBuilder: (_, __) => const Divider(),
|
||||
decoration: BoxDecoration(
|
||||
color: colorScheme.primaryContainer,
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
child: Text(
|
||||
'${results.length}',
|
||||
style: TextStyle(
|
||||
color: colorScheme.onPrimaryContainer,
|
||||
fontSize: 12,
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
),
|
||||
if (results.isNotEmpty) ...[
|
||||
const SizedBox(width: 8),
|
||||
IconButton(
|
||||
icon: Icon(
|
||||
Icons.clear_all,
|
||||
color: colorScheme.error,
|
||||
size: 20,
|
||||
),
|
||||
onPressed: () => onClearTap(null),
|
||||
tooltip: 'Clear all logs',
|
||||
padding: EdgeInsets.zero,
|
||||
constraints: const BoxConstraints(
|
||||
minWidth: 32,
|
||||
minHeight: 32,
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildEmptyState(ColorScheme colorScheme) {
|
||||
final emptyStateContent = Padding(
|
||||
padding: const EdgeInsets.all(32.0),
|
||||
child: Center(
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Icon(
|
||||
Icons.history_outlined,
|
||||
size: 48,
|
||||
color: colorScheme.onSurface.withValues(alpha: 0.3),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
Text(
|
||||
'No logs yet',
|
||||
style: TextStyle(
|
||||
color: colorScheme.onSurface.withValues(alpha: 0.6),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
|
||||
return scrollable ? Expanded(child: emptyStateContent) : emptyStateContent;
|
||||
}
|
||||
|
||||
Widget _buildLogsList(ColorScheme colorScheme) {
|
||||
final listView = ListView.separated(
|
||||
physics: const AlwaysScrollableScrollPhysics(),
|
||||
controller: scrollController,
|
||||
itemCount: results.length,
|
||||
itemBuilder: (context, index) => _buildLogItem(colorScheme, index),
|
||||
separatorBuilder: (_, __) => Divider(
|
||||
height: 1,
|
||||
color: colorScheme.outline.withValues(alpha: 0.2),
|
||||
),
|
||||
);
|
||||
|
||||
if (scrollable) {
|
||||
return Expanded(child: listView);
|
||||
} else {
|
||||
return Container(
|
||||
constraints: const BoxConstraints(maxHeight: 300),
|
||||
child: listView,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Widget _buildLogItem(ColorScheme colorScheme, int index) {
|
||||
var reversedIndex = results.length - index - 1;
|
||||
final log = results[reversedIndex];
|
||||
return InkWell(
|
||||
onTap: () => onClearTap(reversedIndex),
|
||||
child: Container(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 16,
|
||||
vertical: 12,
|
||||
),
|
||||
child: Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Icon(
|
||||
Icons.circle,
|
||||
size: 6,
|
||||
color: colorScheme.primary.withValues(alpha: 0.6),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: SelectableText(
|
||||
log,
|
||||
style: TextStyle(
|
||||
fontSize: 12,
|
||||
fontFamily: 'monospace',
|
||||
color: colorScheme.onSurface,
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Icon(
|
||||
Icons.close,
|
||||
size: 16,
|
||||
color: colorScheme.onSurface.withValues(alpha: 0.4),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,78 +1,222 @@
|
||||
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 {
|
||||
final List<BleService> discoveredServices;
|
||||
final bool scrollable;
|
||||
final void Function(
|
||||
BleService service,
|
||||
BleCharacteristic characteristic,
|
||||
)? onTap;
|
||||
final void Function(BleService service, BleCharacteristic characteristic)?
|
||||
onTap;
|
||||
final BleService? selectedService;
|
||||
final BleCharacteristic? selectedCharacteristic;
|
||||
final Set<String>? favoriteServices;
|
||||
final Map<String, bool>? subscribedCharacteristics;
|
||||
final void Function(String serviceUuid)? onFavoriteToggle;
|
||||
|
||||
const ServicesListWidget({
|
||||
super.key,
|
||||
required this.discoveredServices,
|
||||
this.onTap,
|
||||
this.scrollable = false,
|
||||
this.selectedService,
|
||||
this.selectedCharacteristic,
|
||||
this.favoriteServices,
|
||||
this.subscribedCharacteristics,
|
||||
this.onFavoriteToggle,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final colorScheme = Theme.of(context).colorScheme;
|
||||
final favoriteStarColor = Colors.amber;
|
||||
final subscribedNotificationIconColor = Colors.green;
|
||||
final selectedColor = colorScheme.primary;
|
||||
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 aIsSystem = isSystemService(a.uuid);
|
||||
final bIsSystem = isSystemService(b.uuid);
|
||||
if (aIsSystem != bIsSystem) {
|
||||
return aIsSystem ? 1 : -1;
|
||||
}
|
||||
return 0;
|
||||
});
|
||||
return ListView.builder(
|
||||
shrinkWrap: !scrollable,
|
||||
physics: scrollable ? null : const NeverScrollableScrollPhysics(),
|
||||
itemCount: discoveredServices.length,
|
||||
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;
|
||||
return Padding(
|
||||
padding: const EdgeInsets.all(8.0),
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8.0, vertical: 4.0),
|
||||
child: Card(
|
||||
elevation: isSelected ? 2 : 1,
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
side: isSelected
|
||||
? BorderSide(color: selectedColor, width: 2)
|
||||
: BorderSide.none,
|
||||
),
|
||||
child: ExpandablePanel(
|
||||
header: Padding(
|
||||
padding: const EdgeInsets.all(8.0),
|
||||
padding: const EdgeInsets.all(12.0),
|
||||
child: Row(
|
||||
children: [
|
||||
const Icon(Icons.arrow_forward_ios),
|
||||
Expanded(child: Text(discoveredServices[index].uuid)),
|
||||
Icon(
|
||||
Icons.arrow_forward_ios,
|
||||
size: 14,
|
||||
color: colorScheme.onSurface.withValues(alpha: 0.6),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Expanded(
|
||||
child: Text(
|
||||
service.uuid,
|
||||
style: TextStyle(
|
||||
fontSize: 13,
|
||||
fontFamily: 'monospace',
|
||||
color: colorScheme.onSurface,
|
||||
fontWeight:
|
||||
isSelected ? FontWeight.bold : FontWeight.w500,
|
||||
),
|
||||
),
|
||||
),
|
||||
if (onFavoriteToggle != null) ...[
|
||||
const SizedBox(width: 8),
|
||||
IconButton(
|
||||
icon: Icon(
|
||||
isFavorite ? Icons.star : Icons.star_border,
|
||||
color: isFavorite
|
||||
? favoriteStarColor
|
||||
: colorScheme.onSurface.withValues(alpha: 0.4),
|
||||
),
|
||||
onPressed: () => onFavoriteToggle!(service.uuid),
|
||||
iconSize: 20,
|
||||
padding: EdgeInsets.zero,
|
||||
constraints: const BoxConstraints(
|
||||
minWidth: 32,
|
||||
minHeight: 32,
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
collapsed: const SizedBox(),
|
||||
expanded: Column(
|
||||
children: discoveredServices[index]
|
||||
.characteristics
|
||||
.map((e) => Padding(
|
||||
padding: const EdgeInsets.all(8.0),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
InkWell(
|
||||
onTap: () {
|
||||
onTap?.call(discoveredServices[index], e);
|
||||
},
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
expanded: Padding(
|
||||
padding: const EdgeInsets.only(bottom: 8.0),
|
||||
child: Column(
|
||||
children: service.characteristics.map((e) {
|
||||
final isCharSelected =
|
||||
selectedCharacteristic?.uuid == e.uuid;
|
||||
final isSubscribed =
|
||||
subscribedCharacteristics?[e.uuid] ?? false;
|
||||
return Padding(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 8.0,
|
||||
vertical: 4.0,
|
||||
),
|
||||
child: Container(
|
||||
decoration: BoxDecoration(
|
||||
color: isCharSelected
|
||||
? selectedCharacteristicBackgroundColor
|
||||
: colorScheme.surfaceContainerHighest
|
||||
.withValues(alpha: 0.3),
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
border: isCharSelected
|
||||
? Border.all(color: selectedColor, width: 1.5)
|
||||
: null,
|
||||
),
|
||||
child: InkWell(
|
||||
onTap: () {
|
||||
onTap?.call(service, e);
|
||||
},
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(12.0),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
const Icon(Icons.arrow_right_outlined),
|
||||
Expanded(child: Text(e.uuid)),
|
||||
],
|
||||
Icon(
|
||||
Icons.arrow_right_outlined,
|
||||
size: 16,
|
||||
color: isCharSelected
|
||||
? selectedColor
|
||||
: colorScheme.onSurface
|
||||
.withValues(alpha: 0.6),
|
||||
),
|
||||
Text(
|
||||
"Properties: ${e.properties.map((e) => e.name)}",
|
||||
const SizedBox(width: 8),
|
||||
if (isSubscribed) ...[
|
||||
Icon(
|
||||
Icons.notifications_active,
|
||||
color: subscribedNotificationIconColor,
|
||||
size: 16,
|
||||
),
|
||||
const SizedBox(width: 4),
|
||||
],
|
||||
Expanded(
|
||||
child: Text(
|
||||
e.uuid,
|
||||
style: TextStyle(
|
||||
fontSize: 12,
|
||||
fontFamily: 'monospace',
|
||||
fontWeight: isCharSelected
|
||||
? FontWeight.bold
|
||||
: FontWeight.w500,
|
||||
color: isCharSelected
|
||||
? selectedColor
|
||||
: colorScheme.onSurface,
|
||||
),
|
||||
),
|
||||
),
|
||||
if (e.descriptors.isNotEmpty)
|
||||
Text(
|
||||
"Descriptors: ${e.descriptors.map((e) => e.uuid).join(', ')}",
|
||||
)
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
const SizedBox(height: 8),
|
||||
Wrap(
|
||||
spacing: 6,
|
||||
runSpacing: 6,
|
||||
children: e.properties.map((prop) {
|
||||
return Container(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 6,
|
||||
vertical: 2,
|
||||
),
|
||||
decoration: BoxDecoration(
|
||||
color: colorScheme.secondaryContainer,
|
||||
borderRadius: BorderRadius.circular(4),
|
||||
),
|
||||
child: Text(
|
||||
prop.name,
|
||||
style: TextStyle(
|
||||
fontSize: 10,
|
||||
color:
|
||||
colorScheme.onSecondaryContainer,
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
);
|
||||
}).toList(),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
))
|
||||
.toList(),
|
||||
),
|
||||
),
|
||||
);
|
||||
}).toList(),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
@@ -0,0 +1,105 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:universal_ble/universal_ble.dart';
|
||||
|
||||
class ServicesSideWidget extends StatelessWidget {
|
||||
final List<BleService> discoveredServices;
|
||||
final Function() serviceListBuilder;
|
||||
const ServicesSideWidget({
|
||||
super.key,
|
||||
required this.discoveredServices,
|
||||
required this.serviceListBuilder,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final colorScheme = Theme.of(context).colorScheme;
|
||||
return Container(
|
||||
decoration: BoxDecoration(
|
||||
color: colorScheme.surfaceContainerHighest,
|
||||
border: Border(
|
||||
right: BorderSide(
|
||||
color: colorScheme.outline.withValues(alpha: 0.2),
|
||||
width: 1,
|
||||
),
|
||||
),
|
||||
),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Container(
|
||||
padding: const EdgeInsets.all(16),
|
||||
decoration: BoxDecoration(
|
||||
color: colorScheme.surface,
|
||||
border: Border(
|
||||
bottom: BorderSide(
|
||||
color: colorScheme.outline.withValues(alpha: 0.2),
|
||||
width: 1,
|
||||
),
|
||||
),
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
Icon(
|
||||
Icons.apps,
|
||||
color: colorScheme.primary,
|
||||
size: 20,
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Text(
|
||||
'Services',
|
||||
style: TextStyle(
|
||||
fontWeight: FontWeight.bold,
|
||||
fontSize: 16,
|
||||
color: colorScheme.onSurface,
|
||||
),
|
||||
),
|
||||
const Spacer(),
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 8,
|
||||
vertical: 4,
|
||||
),
|
||||
decoration: BoxDecoration(
|
||||
color: colorScheme.primaryContainer,
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
child: Text(
|
||||
'${discoveredServices.length}',
|
||||
style: TextStyle(
|
||||
color: colorScheme.onPrimaryContainer,
|
||||
fontSize: 12,
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
Expanded(
|
||||
child: discoveredServices.isEmpty
|
||||
? Center(
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Icon(
|
||||
Icons.apps_outlined,
|
||||
size: 48,
|
||||
color: colorScheme.onSurface.withValues(alpha: 0.3),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
Text(
|
||||
'No Services Discovered',
|
||||
style: TextStyle(
|
||||
color: colorScheme.onSurface.withValues(alpha: 0.6),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
)
|
||||
: serviceListBuilder(),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user