diff --git a/android/src/main/kotlin/com/navideck/universal_ble/UniversalBlePlugin.kt b/android/src/main/kotlin/com/navideck/universal_ble/UniversalBlePlugin.kt index 0faaae2..59558d9 100644 --- a/android/src/main/kotlin/com/navideck/universal_ble/UniversalBlePlugin.kt +++ b/android/src/main/kotlin/com/navideck/universal_ble/UniversalBlePlugin.kt @@ -270,7 +270,7 @@ class UniversalBlePlugin : UniversalBlePlatformChannel, BluetoothGattCallback(), UniversalBleLogger.logError("Device might be connected but not known to this app") BleConnectionState.Disconnected.value } - } catch (e: Exception) { + } catch (_: Exception) { return BleConnectionState.Disconnected.value } } diff --git a/example/android/app/build.gradle b/example/android/app/build.gradle index 500663a..539e152 100644 --- a/example/android/app/build.gradle +++ b/example/android/app/build.gradle @@ -23,7 +23,7 @@ if (flutterVersionName == null) { } android { - namespace "com.navideck.universal_ble_example" + namespace = "com.navideck.universalble" compileSdkVersion 36 ndkVersion flutter.ndkVersion @@ -41,7 +41,7 @@ android { } defaultConfig { - applicationId "com.navideck.universal_ble_example" + applicationId = "com.navideck.universalble" // You can update the following values to match your application needs. // For more information, see: https://docs.flutter.dev/deployment/android#reviewing-the-gradle-build-configuration. minSdkVersion flutter.minSdkVersion diff --git a/example/android/app/src/main/AndroidManifest.xml b/example/android/app/src/main/AndroidManifest.xml index 191b9de..30fcaa8 100644 --- a/example/android/app/src/main/AndroidManifest.xml +++ b/example/android/app/src/main/AndroidManifest.xml @@ -6,7 +6,7 @@ CFBundleInfoDictionaryVersion 6.0 CFBundleName - universal_ble_example + Universal BLE CFBundlePackageType APPL CFBundleShortVersionString diff --git a/example/lib/data/mock_universal_ble.dart b/example/lib/data/mock_universal_ble.dart index ffbeb7e..9539f6a 100644 --- a/example/lib/data/mock_universal_ble.dart +++ b/example/lib/data/mock_universal_ble.dart @@ -5,6 +5,7 @@ import 'package:universal_ble/universal_ble.dart'; /// Mock implementation of [UniversalBlePlatform] for testing class MockUniversalBle extends UniversalBlePlatform { + final Map _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 connect(String deviceId, {Duration? connectionTimeout}) async { updateConnection(deviceId, true); + _connectionStateMap[deviceId] = BleConnectionState.connected; } @override Future disconnect(String deviceId) async { updateConnection(deviceId, false); + _connectionStateMap[deviceId] = BleConnectionState.disconnected; } @override @@ -71,7 +82,7 @@ class MockUniversalBle extends UniversalBlePlatform { @override Future> getSystemDevices(List? withServices) async { - return []; + return [_mockBleDevice]; } @override @@ -124,17 +135,18 @@ class MockUniversalBle extends UniversalBlePlatform { } @override - Future getConnectionState(String deviceId) { - throw UnimplementedError(); + Future getConnectionState(String deviceId) async { + return _connectionStateMap[deviceId] ?? BleConnectionState.disconnected; } @override - Future disableBluetooth() { - throw UnimplementedError(); + Future disableBluetooth() async { + return true; } @override - Future requestPermissions({bool withAndroidFineLocation = false}) { - throw UnimplementedError(); + Future requestPermissions( + {bool withAndroidFineLocation = false}) async { + return; } } diff --git a/example/lib/data/storage_service.dart b/example/lib/data/storage_service.dart new file mode 100644 index 0000000..c52f3a3 --- /dev/null +++ b/example/lib/data/storage_service.dart @@ -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 init() async { + _preferences = await SharedPreferencesWithCache.create( + cacheOptions: const SharedPreferencesWithCacheOptions(), + ); + } + + Future setFavoriteServices(List services) async { + await _preferences.setStringList('favorite_services', services); + } + + List getFavoriteServices() => + _preferences.getStringList('favorite_services') ?? []; +} diff --git a/example/lib/data/utils.dart b/example/lib/data/utils.dart new file mode 100644 index 0000000..1900dad --- /dev/null +++ b/example/lib/data/utils.dart @@ -0,0 +1,7 @@ +bool isSystemService(String uuid) { + final normalized = uuid.toUpperCase().replaceAll('-', ''); + return normalized == '00001800' || + normalized == '00001801' || + normalized == '0000180A' || + normalized.startsWith('000018'); +} diff --git a/example/lib/home/home.dart b/example/lib/home/home.dart deleted file mode 100644 index 0ab9c57..0000000 --- a/example/lib/home/home.dart +++ /dev/null @@ -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 { - final _bleDevices = []; - final _hiddenDevices = []; - bool _isScanning = false; - QueueType _queueType = QueueType.global; - TextEditingController servicesFilterController = TextEditingController(); - TextEditingController namePrefixController = TextEditingController(); - TextEditingController manufacturerDataController = TextEditingController(); - StreamSubscription? _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 startScan() async { - await UniversalBle.startScan(scanFilter: scanFilter); - } - - Future _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 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; - }); - }, - ); - }, - ), - ), - ], - ), - ); - } -} diff --git a/example/lib/home/permission_screen.dart b/example/lib/home/permission_screen.dart new file mode 100644 index 0000000..8214cca --- /dev/null +++ b/example/lib/home/permission_screen.dart @@ -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 createState() => _PermissionScreenState(); +} + +class _PermissionScreenState extends State + 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 _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 _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( + 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( + 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(), + ], + ), + ), + ), + ), + ); + } +} diff --git a/example/lib/home/scanner_screen.dart b/example/lib/home/scanner_screen.dart new file mode 100644 index 0000000..7153354 --- /dev/null +++ b/example/lib/home/scanner_screen.dart @@ -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 { + final _bleDevices = []; + final _hiddenDevices = []; + 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? _scanSubscription; + + AvailabilityState? bleAvailabilityState; + ScanFilter? scanFilter; + final Map _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 _startScan() async { + setState(() { + _bleDevices.clear(); + _isScanning = true; + }); + try { + PlatformConfig? platformConfig; + if (kIsWeb && _webServicesController.text.isNotEmpty) { + List 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 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( + 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; + }); + }, + ); + }, + ), + ), + ], + ), + ); + } +} diff --git a/example/lib/home/system_devices_screen.dart b/example/lib/home/system_devices_screen.dart new file mode 100644 index 0000000..e96051b --- /dev/null +++ b/example/lib/home/system_devices_screen.dart @@ -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 createState() => _SystemDevicesScreenState(); +} + +class _SystemDevicesScreenState extends State { + List _systemDevices = []; + bool _isLoading = false; + AvailabilityState? bleAvailabilityState; + List withServices = []; + final Map _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 _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 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( + 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), + ), + ); + }, + ); + }, + ), + ), + ], + ), + ); + } +} diff --git a/example/lib/home/widgets/ble_availability_icon.dart b/example/lib/home/widgets/ble_availability_icon.dart new file mode 100644 index 0000000..265240a --- /dev/null +++ b/example/lib/home/widgets/ble_availability_icon.dart @@ -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 createState() => _BleAvailabilityIconState(); +} + +class _BleAvailabilityIconState extends State { + AvailabilityState? bleAvailabilityState; + StreamSubscription? _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), + ), + }; + } +} diff --git a/example/lib/home/widgets/ble_device_info_widget.dart b/example/lib/home/widgets/ble_device_info_widget.dart new file mode 100644 index 0000000..52c06d9 --- /dev/null +++ b/example/lib/home/widgets/ble_device_info_widget.dart @@ -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'}', + ), + ], + ), + ); + } +} diff --git a/example/lib/home/widgets/drawer.dart b/example/lib/home/widgets/drawer.dart new file mode 100644 index 0000000..e4a0f1f --- /dev/null +++ b/example/lib/home/widgets/drawer.dart @@ -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 createState() => _AppDrawerState(); +} + +class _AppDrawerState extends State { + 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")); + }, + ), + ], + ), + ), + ], + ), + ), + ], + ), + ); + } +} diff --git a/example/lib/home/widgets/queue_selector_widget.dart b/example/lib/home/widgets/queue_selector_widget.dart new file mode 100644 index 0000000..e84ca75 --- /dev/null +++ b/example/lib/home/widgets/queue_selector_widget.dart @@ -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, + ), + ], + ), + ), + ); + } +} diff --git a/example/lib/home/widgets/rssi_signal_indicator.dart b/example/lib/home/widgets/rssi_signal_indicator.dart new file mode 100644 index 0000000..7e4a9ea --- /dev/null +++ b/example/lib/home/widgets/rssi_signal_indicator.dart @@ -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'), + ], + ); + } +} diff --git a/example/lib/home/widgets/scan_filter_widget.dart b/example/lib/home/widgets/scan_filter_widget.dart index c2da8b7..319ae4b 100644 --- a/example/lib/home/widgets/scan_filter_widget.dart +++ b/example/lib/home/widgets/scan_filter_widget.dart @@ -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 { List namePrefixes = []; List manufacturerDataFilters = []; - // Parse Services + // Parse Services - handle both comma and newline separated if (widget.servicesFilterController.text.isNotEmpty) { - List services = widget.servicesFilterController.text.split(','); + List 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 { } } - // 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 manufacturerData = manufacturerDataText.split(','); + List 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 { @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), - ], + ], + ), ), ); } diff --git a/example/lib/home/widgets/scanned_devices_placeholder_widget.dart b/example/lib/home/widgets/scanned_devices_placeholder_widget.dart index bcc7437..db615ff 100644 --- a/example/lib/home/widgets/scanned_devices_placeholder_widget.dart +++ b/example/lib/home/widgets/scanned_devices_placeholder_widget.dart @@ -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), - ) - ], + ), ); } } diff --git a/example/lib/home/widgets/scanned_item_widget.dart b/example/lib/home/widgets/scanned_item_widget.dart index 37f5a82..1d8a73c 100644 --- a/example/lib/home/widgets/scanned_item_widget.dart +++ b/example/lib/home/widgets/scanned_item_widget.dart @@ -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 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, ), ), ); diff --git a/example/lib/main.dart b/example/lib/main.dart index 01f1d47..8aa7356 100644 --- a/example/lib/main.dart +++ b/example/lib/main.dart @@ -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(), + ); + } } diff --git a/example/lib/peripheral_details/peripheral_detail_page.dart b/example/lib/peripheral_details/peripheral_detail_page.dart index d940b3f..105cb68 100644 --- a/example/lib/peripheral_details/peripheral_detail_page.dart +++ b/example/lib/peripheral_details/peripheral_detail_page.dart @@ -3,11 +3,12 @@ import 'dart:async'; import 'package:convert/convert.dart'; import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; import 'package:universal_ble/universal_ble.dart'; +import 'package:universal_ble_example/data/storage_service.dart'; import 'package:universal_ble_example/peripheral_details/widgets/result_widget.dart'; import 'package:universal_ble_example/peripheral_details/widgets/services_list_widget.dart'; -import 'package:universal_ble_example/widgets/platform_button.dart'; -import 'package:universal_ble_example/widgets/responsive_buttons_grid.dart'; +import 'package:universal_ble_example/peripheral_details/widgets/services_side_widget.dart'; import 'package:universal_ble_example/widgets/responsive_view.dart'; class PeripheralDetailPage extends StatefulWidget { @@ -27,32 +28,47 @@ class _PeripheralDetailPageState extends State { List discoveredServices = []; final List _logs = []; final binaryCode = TextEditingController(); + bool _isLoading = false; + bool _isDeviceInfoExpanded = false; + bool _isDeviceActionsExpanded = true; + final Map _subscribedCharacteristics = {}; StreamSubscription? connectionStreamSubscription; StreamSubscription? pairingStateSubscription; BleService? selectedService; BleCharacteristic? selectedCharacteristic; + final ScrollController _logsScrollController = ScrollController(); + final Set _favoriteServices = {}; + + void _loadFavoriteServices() { + final favorites = StorageService.instance.getFavoriteServices(); + setState(() { + _favoriteServices.addAll(favorites); + }); + } + + Future _saveFavoriteServices() async { + await StorageService.instance.setFavoriteServices( + _favoriteServices.toList(), + ); + } @override void initState() { super.initState(); - connectionStreamSubscription = - bleDevice.connectionStream.listen(_handleConnectionChange); - pairingStateSubscription = - bleDevice.pairingStateStream.listen(_handlePairingStateChange); + connectionStreamSubscription = bleDevice.connectionStream.listen( + _handleConnectionChange, + ); + pairingStateSubscription = bleDevice.pairingStateStream.listen( + _handlePairingStateChange, + ); UniversalBle.onValueChange = _handleValueChange; - _asyncInits(); - } - void _asyncInits() { bleDevice.connectionState.then((state) { - if (state == BleConnectionState.connected) { - setState(() { - isConnected = true; - }); - } + _handleConnectionChange(state == BleConnectionState.connected); }); + _loadFavoriteServices(); } @override @@ -81,8 +97,12 @@ class _PeripheralDetailPageState extends State { } } - void _handleValueChange(String deviceId, String characteristicId, - Uint8List value, int? timestamp) { + void _handleValueChange( + String deviceId, + String characteristicId, + Uint8List value, + int? timestamp, + ) { String s = String.fromCharCodes(value); String data = '$s\nraw : ${value.toString()}'; DateTime? timestampDateTime = timestamp != null @@ -100,39 +120,45 @@ class _PeripheralDetailPageState extends State { Future _discoverServices() async { const webWarning = "Note: Only services added in ScanFilter or WebOptions will be discovered"; - try { - var services = await bleDevice.discoverServices(withDescriptors: false); - debugPrint('${services.length} services discovered'); - debugPrint(services.toString()); - setState(() { - discoveredServices = services; - }); - if (kIsWeb) { - _addLog( - "DiscoverServices", - '${services.length} services discovered,\n$webWarning', - ); - } - } catch (e) { - _addLog("DiscoverServicesError", '$e\n${kIsWeb ? webWarning : ""}'); - } + await _executeWithLoading( + () async { + var services = await bleDevice.discoverServices(withDescriptors: false); + debugPrint('${services.length} services discovered'); + debugPrint(services.toString()); + setState(() { + discoveredServices = services; + }); + if (kIsWeb) { + _addLog( + "DiscoverServices", + '${services.length} services discovered,\n$webWarning', + ); + } + }, + onError: (error) { + _addLog("DiscoverServicesError", '$error\n${kIsWeb ? webWarning : ""}'); + }, + ); } Future _readValue() async { BleCharacteristic? selectedCharacteristic = this.selectedCharacteristic; if (selectedCharacteristic == null) return; - try { - Uint8List value = await selectedCharacteristic.read(); - String s = String.fromCharCodes(value); - String data = '$s\nraw : ${value.toString()}'; - _addLog('Read', data); - } catch (e) { - _addLog('ReadError', e); - } + await _executeWithLoading( + () async { + Uint8List value = await selectedCharacteristic.read(); + String s = String.fromCharCodes(value); + String data = '$s\nraw : ${value.toString()}'; + _addLog('Read', data); + }, + onError: (error) { + _addLog('ReadError', error); + }, + ); } - Future _writeValue({required bool withResponse}) async { + Future _writeValue() async { BleCharacteristic? selectedCharacteristic = this.selectedCharacteristic; if (selectedCharacteristic == null || !valueFormKey.currentState!.validate() || @@ -148,41 +174,102 @@ class _PeripheralDetailPageState extends State { return; } - try { - await selectedCharacteristic.write(value, withResponse: withResponse); - _addLog('Write${withResponse ? "" : "WithoutResponse"}', value); - } catch (e) { - debugPrint(e.toString()); - _addLog('WriteError', e); + bool writeWithResponse = true; + if (!selectedCharacteristic.properties.contains( + CharacteristicProperty.write, + )) { + writeWithResponse = false; } + + await _executeWithLoading( + () async { + await selectedCharacteristic.write( + value, + withResponse: writeWithResponse, + ); + _addLog('Write${writeWithResponse ? "" : "WithoutResponse"}', value); + }, + onError: (error) { + _addLog('WriteError', error); + }, + ); } Future _subscribeChar() async { BleCharacteristic? selectedCharacteristic = this.selectedCharacteristic; if (selectedCharacteristic == null) return; - try { - var subscription = _getCharacteristicSubscription(selectedCharacteristic); - if (subscription == null) throw 'No notify or indicate property'; - await subscription.subscribe(); - _addLog('BleCharSubscription', 'Subscribed'); - // Updates can also be handled by - // subscription.listen((data) {}); - } catch (e) { - _addLog('NotifyError', e); - } + await _executeWithLoading( + () async { + var subscription = _getCharacteristicSubscription( + selectedCharacteristic, + ); + if (subscription == null) throw 'No notify or indicate property'; + await subscription.subscribe(); + setState(() { + _subscribedCharacteristics[selectedCharacteristic.uuid] = true; + }); + _addLog('BleCharSubscription', 'Subscribed'); + }, + onError: (error) { + _addLog('NotifyError', error); + }, + ); } Future _unsubscribeChar() async { - try { - await selectedCharacteristic?.unsubscribe(); - _addLog('BleCharSubscription', 'UnSubscribed'); - } catch (e) { - _addLog('NotifyError', e); - } + final char = selectedCharacteristic; + if (char == null) return; + await _executeWithLoading( + () async { + await char.unsubscribe(); + setState(() { + _subscribedCharacteristics.remove(char.uuid); + }); + _addLog('BleCharSubscription', 'UnSubscribed'); + }, + onError: (error) { + _addLog('NotifyError', error); + }, + ); + } + + Future _subscribeToAllCharacteristics() async { + if (!isConnected) return; + await _executeWithLoading( + () async { + int successCount = 0; + int errorCount = 0; + for (var service in discoveredServices) { + for (var characteristic in service.characteristics) { + var subscription = _getCharacteristicSubscription(characteristic); + if (subscription != null) { + try { + await subscription.subscribe(); + setState(() { + _subscribedCharacteristics[characteristic.uuid] = true; + }); + successCount++; + } catch (e) { + errorCount++; + debugPrint('Failed to subscribe to ${characteristic.uuid}: $e'); + } + } + } + } + _addLog( + 'BleCharSubscription', + 'Subscribed to $successCount characteristics${errorCount > 0 ? ', $errorCount failed' : ''}', + ); + }, + onError: (error) { + _addLog('SubscribeToAllCharacteristicsError', error); + }, + ); } CharacteristicSubscription? _getCharacteristicSubscription( - BleCharacteristic characteristic) { + BleCharacteristic characteristic, + ) { var properties = characteristic.properties; if (properties.contains(CharacteristicProperty.notify)) { return characteristic.notifications; @@ -192,306 +279,1279 @@ class _PeripheralDetailPageState extends State { return null; } + Future _executeWithLoading( + Future Function() action, { + Function(dynamic error)? onError, + }) async { + setState(() { + _isLoading = true; + }); + try { + return await action(); + } catch (e) { + onError?.call(e); + rethrow; + } finally { + setState(() { + _isLoading = false; + }); + } + } + + void _showCharacteristicSelector() { + showModalBottomSheet( + context: context, + shape: const RoundedRectangleBorder( + borderRadius: BorderRadius.vertical(top: Radius.circular(20)), + ), + builder: (context) { + return Padding( + padding: EdgeInsets.only(bottom: 10.0), + child: ServicesSideWidget( + discoveredServices: discoveredServices, + serviceListBuilder: () => _buildServicesList(onSelect: (_, __) { + Navigator.pop(context); + }), + ), + ); + }, + ); + } + + void _showSnackBar(String message) { + if (context.mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text(message), + ), + ); + } + } + + @override + void setState(VoidCallback fn) { + if (mounted) super.setState(fn); + } + @override Widget build(BuildContext context) { - return Scaffold( - appBar: AppBar( - title: Text("${bleDevice.name ?? "Unknown"} - ${bleDevice.deviceId}"), - elevation: 4, - actions: [ - Padding( - padding: const EdgeInsets.all(8.0), - child: Icon( - isConnected - ? Icons.bluetooth_connected - : Icons.bluetooth_disabled, - color: isConnected ? Colors.greenAccent : Colors.red, - size: 20, + final colorScheme = Theme.of(context).colorScheme; + return ResponsiveView( + builder: (_, DeviceType deviceType) { + return Scaffold( + appBar: AppBar( + title: Text( + bleDevice.name ?? "Unknown Device", + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: const TextStyle( + fontWeight: FontWeight.bold, + letterSpacing: 0.5, + ), ), - ) - ], - ), - body: ResponsiveView(builder: (_, DeviceType deviceType) { - return Row( - children: [ - if (deviceType == DeviceType.desktop) - Expanded( - flex: 1, - child: Container( - color: Theme.of(context).secondaryHeaderColor, - child: discoveredServices.isEmpty - ? const Center( - child: Text('No Services Discovered'), - ) - : ServicesListWidget( - discoveredServices: discoveredServices, - scrollable: true, - onTap: (service, characteristic) { - setState(() { - selectedService = service; - selectedCharacteristic = characteristic; - }); - }, - ), + centerTitle: false, + elevation: 0, + actions: [ + Visibility.maintain( + visible: _isLoading, + child: Padding( + padding: const EdgeInsets.all(16.0), + child: SizedBox( + width: 20, + height: 20, + child: CircularProgressIndicator( + strokeWidth: 2, + valueColor: AlwaysStoppedAnimation( + colorScheme.primary, + ), + ), + ), ), ), - Expanded( - flex: 3, - child: Align( - alignment: Alignment.topCenter, + if (deviceType != DeviceType.desktop) + Padding( + padding: const EdgeInsets.all(8.0), + child: Icon( + isConnected + ? Icons.bluetooth_connected + : Icons.bluetooth_disabled, + color: isConnected + ? Colors.green + : colorScheme.onSurface.withValues(alpha: 0.6), + size: 20, + ), + ) + else + Container( + margin: const EdgeInsets.only(right: 16), + padding: + const EdgeInsets.symmetric(horizontal: 12, vertical: 6), + decoration: BoxDecoration( + color: isConnected + ? Colors.green.withValues(alpha: 0.2) + : Colors.orange.withValues(alpha: 0.2), + borderRadius: BorderRadius.circular(12), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon( + isConnected + ? Icons.check_circle + : Icons.bluetooth_disabled, + size: 16, + color: isConnected + ? Colors.green.shade700 + : Colors.orange.shade700, + ), + const SizedBox(width: 6), + Text( + isConnected ? 'Connected' : 'Disconnected', + style: TextStyle( + color: isConnected + ? Colors.green.shade700 + : Colors.orange.shade700, + fontSize: 12, + fontWeight: FontWeight.w600, + ), + ), + ], + ), + ), + ], + ), + body: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // Services side widget on desktop + if (deviceType == DeviceType.desktop) + Expanded( + flex: 1, + child: ServicesSideWidget( + discoveredServices: discoveredServices, + serviceListBuilder: _buildServicesList, + ), + ), + // Main content + Expanded( + flex: 2, child: SingleChildScrollView( child: Column( children: [ - // Top buttons - Padding( - padding: const EdgeInsets.all(8.0), - child: Row( - mainAxisAlignment: MainAxisAlignment.spaceEvenly, - children: [ - PlatformButton( - text: 'Connect', - enabled: !isConnected, - onPressed: () async { - try { - await bleDevice.connect(); - _addLog("ConnectionResult", true); - } catch (e) { - _addLog('ConnectError (${e.runtimeType})', e); - } - }, - ), - PlatformButton( - text: 'Disconnect', - enabled: isConnected, - onPressed: () async { - try { - await bleDevice.disconnect(); - _addLog("DisconnectResult", true); - } catch (e) { - _addLog( - 'DisconnectError (${e.runtimeType})', - e, - ); - } - }, - ), - ], - ), - ), - selectedCharacteristic == null - ? Text(discoveredServices.isEmpty - ? "Please discover services" - : "Please select a characteristic") - : Padding( - padding: const EdgeInsets.symmetric( - horizontal: 8.0, - ), - child: Card( - child: ListTile( - title: SelectableText( - "Characteristic: ${selectedCharacteristic?.uuid}", - ), - subtitle: Column( - crossAxisAlignment: - CrossAxisAlignment.start, - children: [ - SelectableText( - "Service: ${selectedService?.uuid}", - ), - Text( - "Properties: ${selectedCharacteristic?.properties.map((e) => e.name)}", - ), - ], - ), - ), - ), - ), + // Device info with manufacturer data and advertised services + _buildDeviceInfo(), - if (_hasSelectedCharacteristicProperty([ - CharacteristicProperty.write, - CharacteristicProperty.writeWithoutResponse - ])) - Padding( - padding: const EdgeInsets.symmetric(horizontal: 8.0), - child: Form( - key: valueFormKey, - child: Padding( - padding: const EdgeInsets.all(8.0), - child: TextFormField( - controller: binaryCode, - validator: (value) { - if (value == null || value.isEmpty) { - return 'Please enter a value'; - } - try { - hex.decode(binaryCode.text); - return null; - } catch (e) { - return 'Please enter a valid hex value ( without spaces or 0x (e.g. F0BB) )'; - } - }, - decoration: const InputDecoration( - hintText: - "Enter Hex values without spaces or 0x (e.g. F0BB)", - border: OutlineInputBorder(), - ), - ), - ), - ), - ), - const Divider(), - Padding( - padding: const EdgeInsets.all(8.0), - child: ResponsiveButtonsGrid( - children: [ - PlatformButton( - onPressed: () async { - _discoverServices(); - }, - enabled: isConnected, - text: 'Discover Services', - ), - PlatformButton( - onPressed: () async { - _addLog( - 'ConnectionState', - await bleDevice.connectionState, - ); - }, - text: 'Connection State', - ), - if (BleCapabilities.supportsRequestMtuApi) - PlatformButton( - enabled: isConnected, - onPressed: () async { - int mtu = await bleDevice.requestMtu(247); - _addLog('MTU', mtu); - }, - text: 'Request Mtu', - ), - PlatformButton( - enabled: isConnected && - discoveredServices.isNotEmpty && - _hasSelectedCharacteristicProperty([ - CharacteristicProperty.read, - ]), - onPressed: _readValue, - text: 'Read', - ), - PlatformButton( - enabled: isConnected && - discoveredServices.isNotEmpty && - _hasSelectedCharacteristicProperty([ - CharacteristicProperty.write, - ]), - onPressed: () => _writeValue(withResponse: true), - text: 'Write', - ), - PlatformButton( - enabled: isConnected && - discoveredServices.isNotEmpty && - _hasSelectedCharacteristicProperty([ - CharacteristicProperty.writeWithoutResponse, - ]), - onPressed: () => _writeValue(withResponse: false), - text: 'WriteWithoutResponse', - ), - PlatformButton( - enabled: isConnected && - discoveredServices.isNotEmpty && - _hasSelectedCharacteristicProperty([ - CharacteristicProperty.notify, - CharacteristicProperty.indicate - ]), - onPressed: _subscribeChar, - text: 'Subscribe', - ), - PlatformButton( - enabled: isConnected && - discoveredServices.isNotEmpty && - _hasSelectedCharacteristicProperty([ - CharacteristicProperty.notify, - CharacteristicProperty.indicate - ]), - onPressed: _unsubscribeChar, - text: 'Unsubscribe', - ), - PlatformButton( - enabled: BleCapabilities.supportsAllPairingKinds, - onPressed: () async { - try { - await bleDevice.pair( - // pairingCommand: BleCommand( - // service: "", - // characteristic: "", - // ), - ); - _addLog("Pairing Result", true); - } catch (e) { - _addLog('PairError (${e.runtimeType})', e); - } - }, - text: 'Pair', - ), - PlatformButton( - onPressed: () async { - bool? isPaired = await bleDevice.isPaired( - // pairingCommand: BleCommand( - // service: "", - // characteristic: "", - // ), - ); - _addLog('isPaired', isPaired); - }, - text: 'isPaired', - ), - PlatformButton( - onPressed: () async { - await bleDevice.unpair(); - }, - text: 'Unpair', - ), - ], - ), - ), - // Services - if (deviceType != DeviceType.desktop) - ServicesListWidget( - discoveredServices: discoveredServices, - onTap: (service, characteristic) { - setState(() { - selectedService = service; - selectedCharacteristic = characteristic; - }); - }, - ), - const Divider(), - ResultWidget( - results: _logs, - onClearTap: (int? index) { - setState(() { - if (index != null) { - _logs.removeAt(index); - } else { - _logs.clear(); - } - }); - }), + // Connect/Disconnect button + _buildConnectDisconnectButton(), + + // Device Actions + _buildDeviceActions(), + + // Characteristic selector + _buildCharacteristicSelector(), + + // Characteristic Actions + _buildCharacteristicActions(), + + // Logs on bottom for mobile + if (deviceType != DeviceType.desktop) ...[ + const Divider(), + _buildResultWidget(scrollable: false), + ], const SizedBox(height: 20), ], ), ), ), - ), - ], + + // Logs on right side for desktop + if (deviceType == DeviceType.desktop) + Expanded( + flex: 1, + child: _buildResultWidget( + scrollable: true, + ), + ), + ], + ), ); - }), + }, + ); + } + + Widget _buildCharacteristicActions() { + final colorScheme = Theme.of(context).colorScheme; + return Padding( + padding: const EdgeInsets.symmetric( + horizontal: 16.0, + vertical: 8.0, + ), + child: Card( + elevation: 2, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(12), + ), + child: Padding( + padding: const EdgeInsets.all(16.0), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Icon( + Icons.tune, + color: colorScheme.primary, + size: 20, + ), + const SizedBox(width: 8), + Text( + 'Characteristic Actions', + style: TextStyle( + fontWeight: FontWeight.bold, + fontSize: 16, + color: colorScheme.onSurface, + ), + ), + ], + ), + const SizedBox(height: 16), + Form( + key: valueFormKey, + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + TextFormField( + controller: binaryCode, + enabled: isConnected && + _hasSelectedCharacteristicProperty([ + CharacteristicProperty.write, + CharacteristicProperty.writeWithoutResponse, + ]), + validator: (value) { + if (value == null || value.isEmpty) { + return 'Please enter a value'; + } + try { + hex.decode(binaryCode.text); + return null; + } catch (e) { + return 'Please enter a valid hex value ( without spaces or 0x (e.g. F0BB) )'; + } + }, + decoration: InputDecoration( + hintText: + "Enter Hex values without spaces or 0x (e.g. F0BB)", + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(12), + ), + filled: true, + fillColor: colorScheme.surfaceContainerHighest, + prefixIcon: Icon( + Icons.code, + color: colorScheme.primary, + ), + ), + ), + const SizedBox(height: 12), + Row( + children: [ + Expanded( + child: ElevatedButton.icon( + onPressed: isConnected && + _hasSelectedCharacteristicProperty([ + CharacteristicProperty.write, + CharacteristicProperty + .writeWithoutResponse, + ]) + ? _writeValue + : null, + icon: const Icon(Icons.send), + label: const Text('Write'), + style: ElevatedButton.styleFrom( + backgroundColor: colorScheme.primary, + foregroundColor: colorScheme.onPrimary, + padding: const EdgeInsets.symmetric( + vertical: 16, + ), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(12), + ), + ), + ), + ), + const SizedBox(width: 12), + Expanded( + child: ElevatedButton.icon( + onPressed: isConnected && + _hasSelectedCharacteristicProperty([ + CharacteristicProperty.read, + ]) + ? _readValue + : null, + icon: const Icon(Icons.download), + label: const Text('Read'), + style: ElevatedButton.styleFrom( + backgroundColor: colorScheme.secondary, + foregroundColor: colorScheme.onSecondary, + padding: const EdgeInsets.symmetric( + vertical: 16, + ), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(12), + ), + ), + ), + ), + ], + ), + ], + ), + ), + const SizedBox(height: 16), + Wrap( + spacing: 12, + runSpacing: 12, + children: [ + ElevatedButton.icon( + onPressed: isConnected && + discoveredServices.isNotEmpty && + selectedCharacteristic != null && + _hasSelectedCharacteristicProperty([ + CharacteristicProperty.notify, + CharacteristicProperty.indicate, + ]) + ? _subscribeChar + : null, + icon: const Icon(Icons.notifications_active), + label: const Text('Subscribe'), + style: ElevatedButton.styleFrom( + backgroundColor: Colors.green, + foregroundColor: Colors.white, + padding: const EdgeInsets.symmetric( + horizontal: 16, + vertical: 12, + ), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(8), + ), + ), + ), + OutlinedButton.icon( + onPressed: isConnected && + discoveredServices.isNotEmpty && + selectedCharacteristic != null && + _hasSelectedCharacteristicProperty([ + CharacteristicProperty.notify, + CharacteristicProperty.indicate, + ]) + ? _unsubscribeChar + : null, + icon: const Icon(Icons.notifications_off), + label: const Text('Unsubscribe'), + style: OutlinedButton.styleFrom( + foregroundColor: colorScheme.onSurface, + padding: const EdgeInsets.symmetric( + horizontal: 16, + vertical: 12, + ), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(8), + ), + ), + ), + ElevatedButton.icon( + onPressed: isConnected && discoveredServices.isNotEmpty + ? _subscribeToAllCharacteristics + : null, + icon: const Icon(Icons.notifications_active), + label: const Text('Subscribe All'), + style: ElevatedButton.styleFrom( + backgroundColor: Colors.green, + foregroundColor: Colors.white, + padding: const EdgeInsets.symmetric( + horizontal: 16, + vertical: 12, + ), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(8), + ), + ), + ), + ], + ), + ], + ), + ), + ), + ); + } + + Widget _buildConnectDisconnectButton() { + final colorScheme = Theme.of(context).colorScheme; + return SizedBox( + width: double.infinity, + child: Padding( + padding: const EdgeInsets.symmetric( + horizontal: 16.0, + vertical: 8.0, + ), + child: ElevatedButton.icon( + onPressed: () async { + if (isConnected) { + await _executeWithLoading( + () async { + await bleDevice.disconnect(); + _addLog("DisconnectResult", true); + }, + onError: (error) { + _addLog('DisconnectError', error); + }, + ); + } else { + await _executeWithLoading( + () async { + await bleDevice.connect(); + _addLog("ConnectionResult", true); + }, + onError: (error) { + _addLog( + 'ConnectError (${error.runtimeType})', + error, + ); + }, + ); + } + }, + icon: Icon( + isConnected ? Icons.bluetooth_disabled : Icons.bluetooth_connected, + size: 20, + ), + label: Text(isConnected ? 'Disconnect' : 'Connect'), + style: ElevatedButton.styleFrom( + backgroundColor: + isConnected ? colorScheme.error : colorScheme.primary, + foregroundColor: + isConnected ? colorScheme.onError : colorScheme.onPrimary, + padding: const EdgeInsets.symmetric( + vertical: 16, + ), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(12), + ), + elevation: 2, + ), + ), + ), + ); + } + + Widget _buildDeviceInfo() { + final colorScheme = Theme.of(context).colorScheme; + return Padding( + padding: const EdgeInsets.symmetric( + horizontal: 16.0, + vertical: 2.0, + ), + child: Card( + elevation: 2, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(12), + ), + child: Theme( + data: Theme.of(context).copyWith( + dividerColor: Colors.transparent, + ), + child: ExpansionTile( + initiallyExpanded: _isDeviceInfoExpanded, + onExpansionChanged: (expanded) { + setState(() { + _isDeviceInfoExpanded = expanded; + }); + }, + tilePadding: const EdgeInsets.symmetric( + horizontal: 16, + vertical: 8, + ), + childrenPadding: const EdgeInsets.only( + left: 16, + right: 16, + bottom: 16, + ), + leading: Icon( + Icons.info_outline, + color: colorScheme.primary, + size: 24, + ), + title: Text( + 'Device Information', + style: TextStyle( + fontWeight: FontWeight.bold, + fontSize: 18, + color: colorScheme.onSurface, + ), + ), + subtitle: !_isDeviceInfoExpanded + ? Padding( + padding: const EdgeInsets.only(top: 4), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'Name: ${bleDevice.name ?? "Unknown"}', + style: TextStyle( + fontSize: 12, + color: colorScheme.onSurface.withValues(alpha: 0.7), + ), + ), + const SizedBox(height: 2), + Text( + 'ID: ${bleDevice.deviceId}', + style: TextStyle( + fontSize: 11, + fontFamily: 'monospace', + color: colorScheme.onSurface.withValues(alpha: 0.6), + ), + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + ], + ), + ) + : null, + expandedCrossAxisAlignment: CrossAxisAlignment.start, + children: [ + _buildInfoRow( + context, + 'Device ID', + bleDevice.deviceId, + Icons.fingerprint, + ), + const SizedBox(height: 12), + _buildInfoRow( + context, + 'Name', + bleDevice.name ?? "Unknown", + Icons.label, + ), + // Manufacturer data + if (bleDevice.manufacturerDataList.isNotEmpty) ...[ + const SizedBox(height: 16), + Row( + children: [ + Icon( + Icons.memory, + size: 18, + color: colorScheme.secondary, + ), + const SizedBox(width: 8), + Text( + 'Manufacturer Data', + style: TextStyle( + fontWeight: FontWeight.bold, + fontSize: 14, + color: colorScheme.onSurface, + ), + ), + ], + ), + const SizedBox(height: 8), + ...bleDevice.manufacturerDataList.map( + (data) => Padding( + padding: const EdgeInsets.only( + bottom: 8.0, + ), + child: Container( + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + color: colorScheme.secondaryContainer, + borderRadius: BorderRadius.circular(8), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Text( + 'Company ID: ', + style: TextStyle( + fontSize: 12, + fontWeight: FontWeight.w600, + color: colorScheme.onSecondaryContainer, + ), + ), + Expanded( + child: SelectableText( + data.companyIdRadix16, + style: TextStyle( + fontSize: 12, + fontFamily: 'monospace', + color: colorScheme.onSecondaryContainer, + ), + ), + ), + ], + ), + if (data.payloadRadix16.isNotEmpty) ...[ + const SizedBox(height: 4), + Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'Payload: ', + style: TextStyle( + fontSize: 11, + color: colorScheme.onSecondaryContainer + .withValues(alpha: 0.8), + ), + ), + Expanded( + child: SelectableText( + data.payloadRadix16, + style: TextStyle( + fontSize: 11, + fontFamily: 'monospace', + color: colorScheme.onSecondaryContainer + .withValues(alpha: 0.8), + ), + ), + ), + ], + ), + ], + ], + ), + ), + ), + ), + ], + // Advertised services + if (bleDevice.services.isNotEmpty) ...[ + const SizedBox(height: 16), + Row( + children: [ + Icon( + Icons.list, + size: 18, + color: colorScheme.tertiary, + ), + const SizedBox(width: 8), + Text( + 'Advertised Services', + style: TextStyle( + fontWeight: FontWeight.bold, + fontSize: 14, + color: colorScheme.onSurface, + ), + ), + ], + ), + const SizedBox(height: 8), + Wrap( + spacing: 6, + runSpacing: 6, + children: bleDevice.services + .map( + (service) => Container( + padding: const EdgeInsets.symmetric( + horizontal: 10, + vertical: 6, + ), + decoration: BoxDecoration( + color: colorScheme.tertiaryContainer, + borderRadius: BorderRadius.circular(8), + ), + child: SelectableText( + service, + style: TextStyle( + fontSize: 11, + fontFamily: 'monospace', + color: colorScheme.onTertiaryContainer, + fontWeight: FontWeight.w500, + ), + ), + ), + ) + .toList(), + ), + ], + ], + ), + ), + ), + ); + } + + Widget _buildDeviceActions() { + final colorScheme = Theme.of(context).colorScheme; + return Padding( + padding: const EdgeInsets.symmetric( + horizontal: 16.0, + vertical: 2.0, + ), + child: Card( + elevation: 2, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(12), + ), + child: Theme( + data: Theme.of(context).copyWith( + dividerColor: Colors.transparent, + ), + child: ExpansionTile( + initiallyExpanded: _isDeviceActionsExpanded, + onExpansionChanged: (expanded) { + setState(() { + _isDeviceActionsExpanded = expanded; + }); + }, + tilePadding: const EdgeInsets.symmetric( + horizontal: 16, + vertical: 8, + ), + childrenPadding: const EdgeInsets.only( + left: 16, + right: 16, + bottom: 16, + ), + leading: Icon( + Icons.devices, + color: colorScheme.primary, + size: 24, + ), + title: Text( + 'Device Actions', + style: TextStyle( + fontWeight: FontWeight.bold, + fontSize: 18, + color: colorScheme.onSurface, + ), + ), + expandedCrossAxisAlignment: CrossAxisAlignment.start, + children: [ + SizedBox( + width: double.infinity, + child: Wrap( + spacing: 6, + runSpacing: 2, + children: [ + ElevatedButton.icon( + onPressed: isConnected + ? () async { + _discoverServices(); + } + : null, + icon: const Icon(Icons.search), + label: const Text('Discover Services'), + style: ElevatedButton.styleFrom( + backgroundColor: colorScheme.primary, + foregroundColor: colorScheme.onPrimary, + padding: const EdgeInsets.symmetric( + horizontal: 16, + vertical: 12, + ), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(8), + ), + ), + ), + if (discoveredServices.isNotEmpty) + OutlinedButton.icon( + onPressed: () async { + final servicesText = + discoveredServices.map((s) => s.uuid).join('\n'); + await Clipboard.setData( + ClipboardData(text: servicesText), + ); + _showSnackBar('All services copied to clipboard'); + }, + icon: const Icon(Icons.copy), + label: const Text('Copy Services'), + style: OutlinedButton.styleFrom( + foregroundColor: colorScheme.onSurface, + padding: const EdgeInsets.symmetric( + horizontal: 16, + vertical: 12, + ), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(8), + ), + ), + ), + OutlinedButton.icon( + onPressed: () async { + _addLog( + 'ConnectionState', + await bleDevice.connectionState, + ); + }, + icon: const Icon(Icons.info_outline), + label: const Text('State'), + style: OutlinedButton.styleFrom( + foregroundColor: colorScheme.onSurface, + padding: const EdgeInsets.symmetric( + horizontal: 16, + vertical: 12, + ), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(8), + ), + ), + ), + if (BleCapabilities.supportsRequestMtuApi) + ElevatedButton.icon( + onPressed: isConnected + ? () async { + await _executeWithLoading( + () async { + int mtu = await bleDevice.requestMtu(247); + _addLog('MTU', mtu); + }, + onError: (error) { + _addLog('RequestMtuError', error); + }, + ); + } + : null, + icon: const Icon(Icons.speed), + label: const Text('MTU'), + style: ElevatedButton.styleFrom( + backgroundColor: colorScheme.secondary, + foregroundColor: colorScheme.onSecondary, + padding: const EdgeInsets.symmetric( + horizontal: 16, + vertical: 12, + ), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(8), + ), + ), + ), + ElevatedButton.icon( + onPressed: BleCapabilities.supportsAllPairingKinds + ? () async { + await _executeWithLoading( + () async { + await bleDevice.pair(); + _addLog("Pairing Result", true); + }, + onError: (error) { + _addLog('PairError', error); + }, + ); + } + : null, + icon: const Icon(Icons.link), + label: const Text('Pair'), + style: ElevatedButton.styleFrom( + backgroundColor: colorScheme.tertiary, + foregroundColor: colorScheme.onTertiary, + padding: const EdgeInsets.symmetric( + horizontal: 16, + vertical: 12, + ), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(8), + ), + ), + ), + OutlinedButton.icon( + onPressed: () async { + await _executeWithLoading( + () async { + bool? isPaired = await bleDevice.isPaired(); + _addLog('isPaired', isPaired); + }, + onError: (error) { + _addLog('isPairedError', error); + }, + ); + }, + icon: const Icon(Icons.check_circle), + label: const Text('Check Paired'), + style: OutlinedButton.styleFrom( + foregroundColor: colorScheme.onSurface, + padding: const EdgeInsets.symmetric( + horizontal: 16, + vertical: 12, + ), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(8), + ), + ), + ), + OutlinedButton.icon( + onPressed: () async { + await _executeWithLoading( + () async { + await bleDevice.unpair(); + }, + onError: (error) { + _addLog('UnpairError', error); + }, + ); + }, + icon: const Icon(Icons.link_off), + label: const Text('Unpair'), + style: OutlinedButton.styleFrom( + foregroundColor: colorScheme.error, + side: BorderSide( + color: colorScheme.error, + ), + padding: const EdgeInsets.symmetric( + horizontal: 16, + vertical: 12, + ), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(8), + ), + ), + ), + ], + ), + ), + ], + ), + ), + ), ); } bool _hasSelectedCharacteristicProperty( - List properties) { - return properties.any((property) => - selectedCharacteristic?.properties.contains(property) ?? false); + List properties, + ) { + return properties.any( + (property) => + selectedCharacteristic?.properties.contains(property) ?? false, + ); + } + + Widget _buildInfoRow( + BuildContext context, + String label, + String value, + IconData icon, + ) { + final colorScheme = Theme.of(context).colorScheme; + return Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Icon( + icon, + size: 18, + color: colorScheme.onSurface.withValues(alpha: 0.6), + ), + const SizedBox(width: 8), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + label, + style: TextStyle( + fontSize: 12, + color: colorScheme.onSurface.withValues(alpha: 0.6), + fontWeight: FontWeight.w500, + ), + ), + const SizedBox(height: 2), + SelectableText( + value, + style: TextStyle( + fontSize: 14, + color: colorScheme.onSurface, + fontFamily: label == 'Device ID' ? 'monospace' : null, + fontWeight: FontWeight.w500, + ), + ), + ], + ), + ), + ], + ); + } + + Widget _buildCharacteristicSelector() { + final colorScheme = Theme.of(context).colorScheme; + if (selectedCharacteristic == null) { + return Padding( + padding: const EdgeInsets.all(16.0), + child: InkWell( + onTap: () { + if (discoveredServices.isEmpty) { + _discoverServices(); + } else { + _showCharacteristicSelector(); + } + }, + child: Container( + padding: const EdgeInsets.all(16), + decoration: BoxDecoration( + color: colorScheme.surfaceContainerHighest, + borderRadius: BorderRadius.circular(12), + ), + child: Row( + children: [ + Icon( + Icons.info_outline, + color: colorScheme.onSurface.withValues(alpha: 0.6), + ), + const SizedBox(width: 12), + Expanded( + child: Text( + discoveredServices.isEmpty + ? "Please discover services" + : "Please select a characteristic to read/write", + style: TextStyle( + fontStyle: FontStyle.italic, + color: colorScheme.onSurface.withValues(alpha: 0.7), + ), + ), + ), + ], + ), + ), + ), + ); + } else { + return Padding( + padding: const EdgeInsets.symmetric( + horizontal: 16.0, + vertical: 8.0, + ), + child: _buildSelectedCharacteristicCard(), + ); + } + } + + Widget _buildServicesList({ + Function(BleService, BleCharacteristic?)? onSelect, + }) { + return ServicesListWidget( + discoveredServices: discoveredServices, + selectedService: selectedService, + selectedCharacteristic: selectedCharacteristic, + favoriteServices: _favoriteServices, + subscribedCharacteristics: _subscribedCharacteristics, + scrollable: true, + onTap: (service, characteristic) { + setState(() { + selectedService = service; + selectedCharacteristic = characteristic; + }); + onSelect?.call(service, characteristic); + }, + onFavoriteToggle: (serviceUuid) { + setState(() { + if (_favoriteServices.contains( + serviceUuid, + )) { + _favoriteServices.remove(serviceUuid); + } else { + _favoriteServices.add(serviceUuid); + } + }); + _saveFavoriteServices(); + }, + ); + } + + Widget _buildSelectedCharacteristicCard() { + final colorScheme = Theme.of(context).colorScheme; + if (selectedCharacteristic == null) return const SizedBox.shrink(); + return Card( + elevation: 2, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(12), + ), + color: colorScheme.primaryContainer, + child: InkWell( + onTap: _showCharacteristicSelector, + borderRadius: BorderRadius.circular(12), + child: Padding( + padding: const EdgeInsets.all(12.0), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Icon( + Icons.settings, + size: 16, + color: colorScheme.onSurface.withValues(alpha: 0.6), + ), + const SizedBox(width: 6), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + Text( + 'Characteristic', + style: TextStyle( + fontSize: 11, + color: colorScheme.onSurface.withValues(alpha: 0.6), + fontWeight: FontWeight.w500, + ), + ), + const SizedBox(height: 2), + Row( + children: [ + Expanded( + child: InkWell( + onTap: () { + Clipboard.setData(ClipboardData( + text: selectedCharacteristic?.uuid ?? "", + )); + _showSnackBar('Copied to clipboard'); + }, + child: Text( + selectedCharacteristic!.uuid, + style: TextStyle( + fontSize: 12, + fontFamily: 'monospace', + color: colorScheme.onSurface, + fontWeight: FontWeight.w500, + ), + ), + ), + ), + ], + ), + ], + ), + ), + Icon( + Icons.arrow_drop_down, + size: 18, + color: colorScheme.onSurface.withValues(alpha: 0.6), + ), + ], + ), + const SizedBox(height: 8), + Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Icon( + Icons.apps, + size: 16, + color: colorScheme.onSurface.withValues(alpha: 0.6), + ), + const SizedBox(width: 6), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + Text( + 'Service', + style: TextStyle( + fontSize: 11, + color: colorScheme.onSurface.withValues(alpha: 0.6), + fontWeight: FontWeight.w500, + ), + ), + const SizedBox(height: 2), + InkWell( + onTap: () { + Clipboard.setData(ClipboardData( + text: selectedService?.uuid ?? "", + )); + _showSnackBar('Copied to clipboard'); + }, + child: Text( + selectedService?.uuid ?? "Unknown", + style: TextStyle( + fontSize: 12, + fontFamily: 'monospace', + color: colorScheme.onSurface, + fontWeight: FontWeight.w500, + ), + ), + ), + ], + ), + ), + ], + ), + const SizedBox(height: 8), + Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Icon( + Icons.tune, + size: 16, + color: colorScheme.onSurface.withValues(alpha: 0.6), + ), + const SizedBox(width: 6), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + Text( + 'Properties', + style: TextStyle( + fontSize: 11, + color: colorScheme.onSurface.withValues(alpha: 0.6), + fontWeight: FontWeight.w500, + ), + ), + const SizedBox(height: 4), + Wrap( + spacing: 4, + runSpacing: 4, + children: + selectedCharacteristic!.properties.map((prop) { + return Container( + padding: const EdgeInsets.symmetric( + horizontal: 6, + vertical: 2, + ), + decoration: BoxDecoration( + color: colorScheme.secondaryContainer, + borderRadius: BorderRadius.circular(4), + ), + child: Text( + prop.name, + style: TextStyle( + fontSize: 10, + color: colorScheme.onSecondaryContainer, + fontWeight: FontWeight.w600, + ), + ), + ); + }).toList(), + ), + ], + ), + ), + ], + ), + ], + ), + ), + ), + ); + } + + Widget _buildResultWidget({ + required bool scrollable, + }) { + return ResultWidget( + results: _logs, + scrollController: _logsScrollController, + scrollable: scrollable, + onClearTap: (int? index) { + setState(() { + if (index != null) { + _logs.removeAt(index); + } else { + _logs.clear(); + } + }); + }, + ); } } diff --git a/example/lib/peripheral_details/widgets/result_widget.dart b/example/lib/peripheral_details/widgets/result_widget.dart index 0a43046..a0fbed9 100644 --- a/example/lib/peripheral_details/widgets/result_widget.dart +++ b/example/lib/peripheral_details/widgets/result_widget.dart @@ -3,48 +3,196 @@ import 'package:flutter/material.dart'; class ResultWidget extends StatelessWidget { final List 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), + ), + ], + ), + ), ); } } diff --git a/example/lib/peripheral_details/widgets/services_list_widget.dart b/example/lib/peripheral_details/widgets/services_list_widget.dart index 15ee23b..ca81612 100644 --- a/example/lib/peripheral_details/widgets/services_list_widget.dart +++ b/example/lib/peripheral_details/widgets/services_list_widget.dart @@ -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 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? favoriteServices; + final Map? 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.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(), + ), ), ), ), diff --git a/example/lib/peripheral_details/widgets/services_side_widget.dart b/example/lib/peripheral_details/widgets/services_side_widget.dart new file mode 100644 index 0000000..1d7be01 --- /dev/null +++ b/example/lib/peripheral_details/widgets/services_side_widget.dart @@ -0,0 +1,105 @@ +import 'package:flutter/material.dart'; +import 'package:universal_ble/universal_ble.dart'; + +class ServicesSideWidget extends StatelessWidget { + final List 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(), + ), + ], + ), + ); + } +} diff --git a/example/linux/CMakeLists.txt b/example/linux/CMakeLists.txt index b375099..76485b0 100644 --- a/example/linux/CMakeLists.txt +++ b/example/linux/CMakeLists.txt @@ -4,10 +4,10 @@ project(runner LANGUAGES CXX) # The name of the executable created for the application. Change this to change # the on-disk name of your application. -set(BINARY_NAME "universal_ble_example") +set(BINARY_NAME "universal_ble") # The unique GTK application identifier for this application. See: # https://wiki.gnome.org/HowDoI/ChooseApplicationID -set(APPLICATION_ID "com.navideck.universal_ble") +set(APPLICATION_ID "com.navideck.universalble") # Explicitly opt in to modern CMake behaviors to avoid warnings with recent # versions of CMake. diff --git a/example/linux/flutter/generated_plugin_registrant.cc b/example/linux/flutter/generated_plugin_registrant.cc index e71a16d..f6f23bf 100644 --- a/example/linux/flutter/generated_plugin_registrant.cc +++ b/example/linux/flutter/generated_plugin_registrant.cc @@ -6,6 +6,10 @@ #include "generated_plugin_registrant.h" +#include void fl_register_plugins(FlPluginRegistry* registry) { + g_autoptr(FlPluginRegistrar) url_launcher_linux_registrar = + fl_plugin_registry_get_registrar_for_plugin(registry, "UrlLauncherPlugin"); + url_launcher_plugin_register_with_registrar(url_launcher_linux_registrar); } diff --git a/example/linux/flutter/generated_plugins.cmake b/example/linux/flutter/generated_plugins.cmake index 2e1de87..f16b4c3 100644 --- a/example/linux/flutter/generated_plugins.cmake +++ b/example/linux/flutter/generated_plugins.cmake @@ -3,6 +3,7 @@ # list(APPEND FLUTTER_PLUGIN_LIST + url_launcher_linux ) list(APPEND FLUTTER_FFI_PLUGIN_LIST diff --git a/example/linux/my_application.cc b/example/linux/my_application.cc index a3e4f92..cd68647 100644 --- a/example/linux/my_application.cc +++ b/example/linux/my_application.cc @@ -40,11 +40,11 @@ static void my_application_activate(GApplication* application) { if (use_header_bar) { GtkHeaderBar* header_bar = GTK_HEADER_BAR(gtk_header_bar_new()); gtk_widget_show(GTK_WIDGET(header_bar)); - gtk_header_bar_set_title(header_bar, "universal_ble_example"); + gtk_header_bar_set_title(header_bar, "Universal BLE"); gtk_header_bar_set_show_close_button(header_bar, TRUE); gtk_window_set_titlebar(window, GTK_WIDGET(header_bar)); } else { - gtk_window_set_title(window, "universal_ble_example"); + gtk_window_set_title(window, "Universal BLE"); } gtk_window_set_default_size(window, 1280, 720); diff --git a/example/macos/Flutter/GeneratedPluginRegistrant.swift b/example/macos/Flutter/GeneratedPluginRegistrant.swift index 7ddba83..26eafea 100644 --- a/example/macos/Flutter/GeneratedPluginRegistrant.swift +++ b/example/macos/Flutter/GeneratedPluginRegistrant.swift @@ -5,8 +5,14 @@ import FlutterMacOS import Foundation +import package_info_plus +import shared_preferences_foundation import universal_ble +import url_launcher_macos func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) { + FPPPackageInfoPlusPlugin.register(with: registry.registrar(forPlugin: "FPPPackageInfoPlusPlugin")) + SharedPreferencesPlugin.register(with: registry.registrar(forPlugin: "SharedPreferencesPlugin")) UniversalBlePlugin.register(with: registry.registrar(forPlugin: "UniversalBlePlugin")) + UrlLauncherPlugin.register(with: registry.registrar(forPlugin: "UrlLauncherPlugin")) } diff --git a/example/macos/Podfile.lock b/example/macos/Podfile.lock index 0c1b92b..6ef0555 100644 --- a/example/macos/Podfile.lock +++ b/example/macos/Podfile.lock @@ -1,22 +1,41 @@ PODS: - FlutterMacOS (1.0.0) + - package_info_plus (0.0.1): + - FlutterMacOS + - shared_preferences_foundation (0.0.1): + - Flutter + - FlutterMacOS - universal_ble (0.0.1): - Flutter - FlutterMacOS + - url_launcher_macos (0.0.1): + - FlutterMacOS DEPENDENCIES: - FlutterMacOS (from `Flutter/ephemeral`) + - package_info_plus (from `Flutter/ephemeral/.symlinks/plugins/package_info_plus/macos`) + - shared_preferences_foundation (from `Flutter/ephemeral/.symlinks/plugins/shared_preferences_foundation/darwin`) - universal_ble (from `Flutter/ephemeral/.symlinks/plugins/universal_ble/darwin`) + - url_launcher_macos (from `Flutter/ephemeral/.symlinks/plugins/url_launcher_macos/macos`) EXTERNAL SOURCES: FlutterMacOS: :path: Flutter/ephemeral + package_info_plus: + :path: Flutter/ephemeral/.symlinks/plugins/package_info_plus/macos + shared_preferences_foundation: + :path: Flutter/ephemeral/.symlinks/plugins/shared_preferences_foundation/darwin universal_ble: :path: Flutter/ephemeral/.symlinks/plugins/universal_ble/darwin + url_launcher_macos: + :path: Flutter/ephemeral/.symlinks/plugins/url_launcher_macos/macos SPEC CHECKSUMS: FlutterMacOS: d0db08ddef1a9af05a5ec4b724367152bb0500b1 + package_info_plus: 12f1c5c2cfe8727ca46cbd0b26677728972d9a5b + shared_preferences_foundation: 5086985c1d43c5ba4d5e69a4e8083a389e2909e6 universal_ble: 65e1257dffc557cc7991a93d253beeddc7c1dc92 + url_launcher_macos: 175a54c831f4375a6cf895875f716ee5af3888ce PODFILE CHECKSUM: 9ebaf0ce3d369aaa26a9ea0e159195ed94724cf3 diff --git a/example/macos/Runner.xcodeproj/project.pbxproj b/example/macos/Runner.xcodeproj/project.pbxproj index 6fa729e..0415455 100644 --- a/example/macos/Runner.xcodeproj/project.pbxproj +++ b/example/macos/Runner.xcodeproj/project.pbxproj @@ -68,7 +68,7 @@ 331C80D7294CF71000263BE5 /* RunnerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RunnerTests.swift; sourceTree = ""; }; 333000ED22D3DE5D00554162 /* Warnings.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = Warnings.xcconfig; sourceTree = ""; }; 335BBD1A22A9A15E00E9071D /* GeneratedPluginRegistrant.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = GeneratedPluginRegistrant.swift; sourceTree = ""; }; - 33CC10ED2044A3C60003C045 /* universal_ble_example.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = universal_ble_example.app; sourceTree = BUILT_PRODUCTS_DIR; }; + 33CC10ED2044A3C60003C045 /* Universal BLE.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = "Universal BLE.app"; sourceTree = BUILT_PRODUCTS_DIR; }; 33CC10F02044A3C60003C045 /* AppDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = ""; }; 33CC10F22044A3C60003C045 /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; name = Assets.xcassets; path = Runner/Assets.xcassets; sourceTree = ""; }; 33CC10F52044A3C60003C045 /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.xib; name = Base; path = Base.lproj/MainMenu.xib; sourceTree = ""; }; @@ -144,7 +144,7 @@ 33CC10EE2044A3C60003C045 /* Products */ = { isa = PBXGroup; children = ( - 33CC10ED2044A3C60003C045 /* universal_ble_example.app */, + 33CC10ED2044A3C60003C045 /* Universal BLE.app */, 331C80D5294CF71000263BE5 /* RunnerTests.xctest */, ); name = Products; @@ -248,7 +248,7 @@ ); name = Runner; productName = Runner; - productReference = 33CC10ED2044A3C60003C045 /* universal_ble_example.app */; + productReference = 33CC10ED2044A3C60003C045 /* Universal BLE.app */; productType = "com.apple.product-type.application"; }; /* End PBXNativeTarget section */ @@ -477,10 +477,10 @@ CURRENT_PROJECT_VERSION = 1; GENERATE_INFOPLIST_FILE = YES; MARKETING_VERSION = 1.0; - PRODUCT_BUNDLE_IDENTIFIER = com.navideck.universalBleExample.RunnerTests; + PRODUCT_BUNDLE_IDENTIFIER = com.navideck.universalble.RunnerTests; PRODUCT_NAME = "$(TARGET_NAME)"; SWIFT_VERSION = 5.0; - TEST_HOST = "$(BUILT_PRODUCTS_DIR)/universal_ble_example.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/universal_ble_example"; + TEST_HOST = "$(BUILT_PRODUCTS_DIR)/Universal BLE.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/Universal BLE"; }; name = Debug; }; @@ -492,10 +492,10 @@ CURRENT_PROJECT_VERSION = 1; GENERATE_INFOPLIST_FILE = YES; MARKETING_VERSION = 1.0; - PRODUCT_BUNDLE_IDENTIFIER = com.navideck.universalBleExample.RunnerTests; + PRODUCT_BUNDLE_IDENTIFIER = com.navideck.universalble.RunnerTests; PRODUCT_NAME = "$(TARGET_NAME)"; SWIFT_VERSION = 5.0; - TEST_HOST = "$(BUILT_PRODUCTS_DIR)/universal_ble_example.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/universal_ble_example"; + TEST_HOST = "$(BUILT_PRODUCTS_DIR)/Universal BLE.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/Universal BLE"; }; name = Release; }; @@ -507,10 +507,10 @@ CURRENT_PROJECT_VERSION = 1; GENERATE_INFOPLIST_FILE = YES; MARKETING_VERSION = 1.0; - PRODUCT_BUNDLE_IDENTIFIER = com.navideck.universalBleExample.RunnerTests; + PRODUCT_BUNDLE_IDENTIFIER = com.navideck.universalble.RunnerTests; PRODUCT_NAME = "$(TARGET_NAME)"; SWIFT_VERSION = 5.0; - TEST_HOST = "$(BUILT_PRODUCTS_DIR)/universal_ble_example.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/universal_ble_example"; + TEST_HOST = "$(BUILT_PRODUCTS_DIR)/Universal BLE.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/Universal BLE"; }; name = Profile; }; diff --git a/example/macos/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme b/example/macos/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme index b3b52f0..5a50511 100644 --- a/example/macos/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme +++ b/example/macos/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme @@ -15,7 +15,7 @@ @@ -31,7 +31,7 @@ @@ -66,7 +66,7 @@ @@ -83,7 +83,7 @@ diff --git a/example/macos/Runner/Assets.xcassets/AppIcon.appiconset/Contents.json b/example/macos/Runner/Assets.xcassets/AppIcon.appiconset/Contents.json index a2ec33f..96d3fee 100644 --- a/example/macos/Runner/Assets.xcassets/AppIcon.appiconset/Contents.json +++ b/example/macos/Runner/Assets.xcassets/AppIcon.appiconset/Contents.json @@ -1,68 +1,68 @@ { - "images" : [ - { - "size" : "16x16", - "idiom" : "mac", - "filename" : "app_icon_16.png", - "scale" : "1x" + "info": { + "version": 1, + "author": "xcode" }, - { - "size" : "16x16", - "idiom" : "mac", - "filename" : "app_icon_32.png", - "scale" : "2x" - }, - { - "size" : "32x32", - "idiom" : "mac", - "filename" : "app_icon_32.png", - "scale" : "1x" - }, - { - "size" : "32x32", - "idiom" : "mac", - "filename" : "app_icon_64.png", - "scale" : "2x" - }, - { - "size" : "128x128", - "idiom" : "mac", - "filename" : "app_icon_128.png", - "scale" : "1x" - }, - { - "size" : "128x128", - "idiom" : "mac", - "filename" : "app_icon_256.png", - "scale" : "2x" - }, - { - "size" : "256x256", - "idiom" : "mac", - "filename" : "app_icon_256.png", - "scale" : "1x" - }, - { - "size" : "256x256", - "idiom" : "mac", - "filename" : "app_icon_512.png", - "scale" : "2x" - }, - { - "size" : "512x512", - "idiom" : "mac", - "filename" : "app_icon_512.png", - "scale" : "1x" - }, - { - "size" : "512x512", - "idiom" : "mac", - "filename" : "app_icon_1024.png", - "scale" : "2x" - } - ], - "info" : { - "version" : 1, - "author" : "xcode" - } -} + "images": [ + { + "size": "16x16", + "idiom": "mac", + "filename": "app_icon_16.png", + "scale": "1x" + }, + { + "size": "16x16", + "idiom": "mac", + "filename": "app_icon_32.png", + "scale": "2x" + }, + { + "size": "32x32", + "idiom": "mac", + "filename": "app_icon_32.png", + "scale": "1x" + }, + { + "size": "32x32", + "idiom": "mac", + "filename": "app_icon_64.png", + "scale": "2x" + }, + { + "size": "128x128", + "idiom": "mac", + "filename": "app_icon_128.png", + "scale": "1x" + }, + { + "size": "128x128", + "idiom": "mac", + "filename": "app_icon_256.png", + "scale": "2x" + }, + { + "size": "256x256", + "idiom": "mac", + "filename": "app_icon_256.png", + "scale": "1x" + }, + { + "size": "256x256", + "idiom": "mac", + "filename": "app_icon_512.png", + "scale": "2x" + }, + { + "size": "512x512", + "idiom": "mac", + "filename": "app_icon_512.png", + "scale": "1x" + }, + { + "size": "512x512", + "idiom": "mac", + "filename": "app_icon_1024.png", + "scale": "2x" + } + ] +} \ No newline at end of file diff --git a/example/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_1024.png b/example/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_1024.png index 82b6f9d..d2e2066 100644 Binary files a/example/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_1024.png and b/example/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_1024.png differ diff --git a/example/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_128.png b/example/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_128.png index 13b35eb..e3f08fe 100644 Binary files a/example/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_128.png and b/example/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_128.png differ diff --git a/example/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_16.png b/example/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_16.png index 0a3f5fa..080adb6 100644 Binary files a/example/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_16.png and b/example/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_16.png differ diff --git a/example/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_256.png b/example/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_256.png index bdb5722..4201728 100644 Binary files a/example/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_256.png and b/example/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_256.png differ diff --git a/example/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_32.png b/example/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_32.png index f083318..3bee483 100644 Binary files a/example/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_32.png and b/example/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_32.png differ diff --git a/example/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_512.png b/example/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_512.png index 326c0e7..8925dc9 100644 Binary files a/example/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_512.png and b/example/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_512.png differ diff --git a/example/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_64.png b/example/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_64.png index 2f1632c..b4bccda 100644 Binary files a/example/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_64.png and b/example/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_64.png differ diff --git a/example/macos/Runner/Configs/AppInfo.xcconfig b/example/macos/Runner/Configs/AppInfo.xcconfig index d550e14..96515de 100644 --- a/example/macos/Runner/Configs/AppInfo.xcconfig +++ b/example/macos/Runner/Configs/AppInfo.xcconfig @@ -5,10 +5,10 @@ // 'flutter create' template. // The application's name. By default this is also the title of the Flutter window. -PRODUCT_NAME = universal_ble_example +PRODUCT_NAME = Universal BLE // The application's bundle identifier -PRODUCT_BUNDLE_IDENTIFIER = com.navideck.universalBleExample +PRODUCT_BUNDLE_IDENTIFIER = com.navideck.universalble // The copyright displayed in application information PRODUCT_COPYRIGHT = Copyright © 2023 com.navideck. All rights reserved. diff --git a/example/package_rename_config.yaml b/example/package_rename_config.yaml new file mode 100644 index 0000000..252744b --- /dev/null +++ b/example/package_rename_config.yaml @@ -0,0 +1,32 @@ +package_rename_config: + android: + app_name: "Universal BLE" + package_name: "com.navideck.universalble" + override_old_package: "com.navideck.universal_ble_example" + lang: "kotlin" + + ios: + app_name: "Universal BLE" + bundle_name: "Universal BLE" + package_name: "com.navideck.universalble" + + linux: + app_name: "Universal BLE" + package_name: "com.navideck.universalble" + exe_name: "universal_ble" + + macos: + app_name: "Universal BLE" + package_name: "com.navideck.universalble" + copyright_notice: "Copyright © 2023 com.navideck. All rights reserved." + + windows: + app_name: "Universal BLE" + organization: "Navideck" + copyright_notice: "Copyright © 2023 com.navideck. All rights reserved." + exe_name: "universal_ble" + + web: + app_name: "Universal BLE" + short_app_name: "Universal BLE" + description: "Universal BLE" diff --git a/example/pubspec.lock b/example/pubspec.lock index 97d521a..11177ac 100644 --- a/example/pubspec.lock +++ b/example/pubspec.lock @@ -1,6 +1,14 @@ # Generated by pub # See https://dart.dev/tools/pub/glossary#lockfile packages: + archive: + dependency: transitive + description: + name: archive + sha256: "2fde1607386ab523f7a36bb3e7edb43bd58e6edaf2ffb29d8a6d578b297fdbbd" + url: "https://pub.dev" + source: hosted + version: "4.0.7" args: dependency: transitive description: @@ -41,6 +49,22 @@ packages: url: "https://pub.dev" source: hosted version: "1.4.0" + checked_yaml: + dependency: transitive + description: + name: checked_yaml + sha256: "959525d3162f249993882720d52b7e0c833978df229be20702b33d48d91de70f" + url: "https://pub.dev" + source: hosted + version: "2.0.4" + cli_util: + dependency: transitive + description: + name: cli_util + sha256: ff6785f7e9e3c38ac98b2fb035701789de90154024a75b6cb926445e83197d1c + url: "https://pub.dev" + source: hosted + version: "0.4.2" clock: dependency: transitive description: @@ -65,6 +89,22 @@ packages: url: "https://pub.dev" source: hosted version: "3.1.2" + crypto: + dependency: transitive + description: + name: crypto + sha256: c8ea0233063ba03258fbcf2ca4d6dadfefe14f02fab57702265467a19f27fadf + url: "https://pub.dev" + source: hosted + version: "3.0.7" + csslib: + dependency: transitive + description: + name: csslib + sha256: "09bad715f418841f976c77db72d5398dc1253c21fb9c0c7f0b0b985860b2d58e" + url: "https://pub.dev" + source: hosted + version: "1.0.2" cupertino_icons: dependency: "direct main" description: @@ -123,6 +163,15 @@ packages: description: flutter source: sdk version: "0.0.0" + flutter_launcher_icons: + dependency: "direct dev" + description: + path: "." + ref: improve-windows-ico-generation + resolved-ref: d623fac15f257f06bfc21ed7afdd0b6f2739ea21 + url: "https://github.com/Navideck/flutter_launcher_icons.git" + source: git + version: "0.14.3" flutter_lints: dependency: "direct dev" description: @@ -144,16 +193,61 @@ packages: url: "https://pub.dev" source: hosted version: "1.1.0" + flutter_web_plugins: + dependency: transitive + description: flutter + source: sdk + version: "0.0.0" fuchsia_remote_debug_protocol: dependency: transitive description: flutter source: sdk version: "0.0.0" + html: + dependency: transitive + description: + name: html + sha256: "6d1264f2dffa1b1101c25a91dff0dc2daee4c18e87cd8538729773c073dbf602" + url: "https://pub.dev" + source: hosted + version: "0.15.6" + http: + dependency: transitive + description: + name: http + sha256: "87721a4a50b19c7f1d49001e51409bddc46303966ce89a65af4f4e6004896412" + url: "https://pub.dev" + source: hosted + version: "1.6.0" + http_parser: + dependency: transitive + description: + name: http_parser + sha256: "178d74305e7866013777bab2c3d8726205dc5a4dd935297175b19a23a2e66571" + url: "https://pub.dev" + source: hosted + version: "4.1.2" + image: + dependency: transitive + description: + name: image + sha256: "51555e36056541237b15b57afc31a0f53d4f9aefd9bd00873a6dc0090e54e332" + url: "https://pub.dev" + source: hosted + version: "4.6.0" integration_test: dependency: "direct dev" description: flutter source: sdk version: "0.0.0" + json_annotation: + dependency: transitive + description: + name: json_annotation + sha256: "1ce844379ca14835a50d2f019a3099f419082cfdd231cd86a142af94dd5c6bb1" + url: "https://pub.dev" + source: hosted + version: "4.9.0" leak_tracker: dependency: transitive description: @@ -186,6 +280,14 @@ packages: url: "https://pub.dev" source: hosted version: "6.0.0" + logger: + dependency: transitive + description: + name: logger + sha256: a7967e31b703831a893bbc3c3dd11db08126fe5f369b5c648a36f821979f5be3 + url: "https://pub.dev" + source: hosted + version: "2.6.2" logging: dependency: transitive description: @@ -218,6 +320,30 @@ packages: url: "https://pub.dev" source: hosted version: "1.17.0" + package_info_plus: + dependency: "direct main" + description: + name: package_info_plus + sha256: f69da0d3189a4b4ceaeb1a3defb0f329b3b352517f52bed4290f83d4f06bc08d + url: "https://pub.dev" + source: hosted + version: "9.0.0" + package_info_plus_platform_interface: + dependency: transitive + description: + name: package_info_plus_platform_interface + sha256: "202a487f08836a592a6bd4f901ac69b3a8f146af552bbd14407b6b41e1c3f086" + url: "https://pub.dev" + source: hosted + version: "3.2.1" + package_rename: + dependency: "direct dev" + description: + name: package_rename + sha256: "8e957670ab3c8ab0aa9976d10dfe8575c279e81a48aeeca9e7de43dbdc2c2be0" + url: "https://pub.dev" + source: hosted + version: "1.10.1" path: dependency: transitive description: @@ -226,6 +352,30 @@ packages: url: "https://pub.dev" source: hosted version: "1.9.1" + path_provider_linux: + dependency: transitive + description: + name: path_provider_linux + sha256: f7a1fe3a634fe7734c8d3f2766ad746ae2a2884abe22e241a8b301bf5cac3279 + url: "https://pub.dev" + source: hosted + version: "2.2.1" + path_provider_platform_interface: + dependency: transitive + description: + name: path_provider_platform_interface + sha256: "88f5779f72ba699763fa3a3b06aa4bf6de76c8e5de842cf6f29e2e06476c2334" + url: "https://pub.dev" + source: hosted + version: "2.1.2" + path_provider_windows: + dependency: transitive + description: + name: path_provider_windows + sha256: bd6f00dbd873bfb70d0761682da2b3a2c2fccc2b9e84c495821639601d81afe7 + url: "https://pub.dev" + source: hosted + version: "2.3.0" petitparser: dependency: transitive description: @@ -250,6 +400,14 @@ packages: url: "https://pub.dev" source: hosted version: "2.1.8" + posix: + dependency: transitive + description: + name: posix + sha256: "6323a5b0fa688b6a010df4905a56b00181479e6d10534cecfecede2aa55add61" + url: "https://pub.dev" + source: hosted + version: "6.0.3" process: dependency: transitive description: @@ -258,6 +416,62 @@ packages: url: "https://pub.dev" source: hosted version: "5.0.5" + shared_preferences: + dependency: "direct main" + description: + name: shared_preferences + sha256: "2939ae520c9024cb197fc20dee269cd8cdbf564c8b5746374ec6cacdc5169e64" + url: "https://pub.dev" + source: hosted + version: "2.5.4" + shared_preferences_android: + dependency: transitive + description: + name: shared_preferences_android + sha256: "34266009473bf71d748912da4bf62d439185226c03e01e2d9687bc65bbfcb713" + url: "https://pub.dev" + source: hosted + version: "2.4.15" + shared_preferences_foundation: + dependency: transitive + description: + name: shared_preferences_foundation + sha256: "1c33a907142607c40a7542768ec9badfd16293bac51da3a4482623d15845f88b" + url: "https://pub.dev" + source: hosted + version: "2.5.5" + shared_preferences_linux: + dependency: transitive + description: + name: shared_preferences_linux + sha256: "580abfd40f415611503cae30adf626e6656dfb2f0cee8f465ece7b6defb40f2f" + url: "https://pub.dev" + source: hosted + version: "2.4.1" + shared_preferences_platform_interface: + dependency: transitive + description: + name: shared_preferences_platform_interface + sha256: "57cbf196c486bc2cf1f02b85784932c6094376284b3ad5779d1b1c6c6a816b80" + url: "https://pub.dev" + source: hosted + version: "2.4.1" + shared_preferences_web: + dependency: transitive + description: + name: shared_preferences_web + sha256: c49bd060261c9a3f0ff445892695d6212ff603ef3115edbb448509d407600019 + url: "https://pub.dev" + source: hosted + version: "2.4.3" + shared_preferences_windows: + dependency: transitive + description: + name: shared_preferences_windows + sha256: "94ef0f72b2d71bc3e700e025db3710911bd51a71cefb65cc609dd0d9a982e3c1" + url: "https://pub.dev" + source: hosted + version: "2.4.1" sky_engine: dependency: transitive description: flutter @@ -334,6 +548,70 @@ packages: relative: true source: path version: "1.0.0" + url_launcher: + dependency: "direct main" + description: + name: url_launcher + sha256: f6a7e5c4835bb4e3026a04793a4199ca2d14c739ec378fdfe23fc8075d0439f8 + url: "https://pub.dev" + source: hosted + version: "6.3.2" + url_launcher_android: + dependency: transitive + description: + name: url_launcher_android + sha256: "767344bf3063897b5cf0db830e94f904528e6dd50a6dfaf839f0abf509009611" + url: "https://pub.dev" + source: hosted + version: "6.3.28" + url_launcher_ios: + dependency: transitive + description: + name: url_launcher_ios + sha256: cfde38aa257dae62ffe79c87fab20165dfdf6988c1d31b58ebf59b9106062aad + url: "https://pub.dev" + source: hosted + version: "6.3.6" + url_launcher_linux: + dependency: transitive + description: + name: url_launcher_linux + sha256: d5e14138b3bc193a0f63c10a53c94b91d399df0512b1f29b94a043db7482384a + url: "https://pub.dev" + source: hosted + version: "3.2.2" + url_launcher_macos: + dependency: transitive + description: + name: url_launcher_macos + sha256: "368adf46f71ad3c21b8f06614adb38346f193f3a59ba8fe9a2fd74133070ba18" + url: "https://pub.dev" + source: hosted + version: "3.2.5" + url_launcher_platform_interface: + dependency: transitive + description: + name: url_launcher_platform_interface + sha256: "552f8a1e663569be95a8190206a38187b531910283c3e982193e4f2733f01029" + url: "https://pub.dev" + source: hosted + version: "2.3.2" + url_launcher_web: + dependency: transitive + description: + name: url_launcher_web + sha256: "4bd2b7b4dc4d4d0b94e5babfffbca8eac1a126c7f3d6ecbc1a11013faa3abba2" + url: "https://pub.dev" + source: hosted + version: "2.4.1" + url_launcher_windows: + dependency: transitive + description: + name: url_launcher_windows + sha256: "712c70ab1b99744ff066053cbe3e80c73332b38d46e5e945c98689b2e66fc15f" + url: "https://pub.dev" + source: hosted + version: "3.1.5" vector_math: dependency: transitive description: @@ -366,6 +644,22 @@ packages: url: "https://pub.dev" source: hosted version: "3.1.0" + win32: + dependency: transitive + description: + name: win32 + sha256: d7cb55e04cd34096cd3a79b3330245f54cb96a370a1c27adb3c84b917de8b08e + url: "https://pub.dev" + source: hosted + version: "5.15.0" + xdg_directories: + dependency: transitive + description: + name: xdg_directories + sha256: "7a3f37b05d989967cdddcbb571f1ea834867ae2faa29725fd085180e0883aa15" + url: "https://pub.dev" + source: hosted + version: "1.1.0" xml: dependency: transitive description: @@ -374,6 +668,14 @@ packages: url: "https://pub.dev" source: hosted version: "6.6.1" + yaml: + dependency: transitive + description: + name: yaml + sha256: b9da305ac7c39faa3f030eccd175340f968459dae4af175130b3fc47e40d76ce + url: "https://pub.dev" + source: hosted + version: "3.1.3" sdks: - dart: ">=3.8.0 <4.0.0" - flutter: ">=3.18.0-18.0.pre.54" + dart: ">=3.9.0 <4.0.0" + flutter: ">=3.35.0" diff --git a/example/pubspec.yaml b/example/pubspec.yaml index 1e012a2..31e737a 100644 --- a/example/pubspec.yaml +++ b/example/pubspec.yaml @@ -12,6 +12,9 @@ dependencies: convert: ^3.1.1 expandable: ^5.0.1 cupertino_icons: ^1.0.2 + shared_preferences: ^2.5.4 + package_info_plus: ^9.0.0 + url_launcher: ^6.3.2 universal_ble: path: ../ @@ -21,6 +24,13 @@ dev_dependencies: flutter_test: sdk: flutter flutter_lints: ^6.0.0 + package_rename: ^1.10.1 + flutter_launcher_icons: + git: + url: https://github.com/Navideck/flutter_launcher_icons.git + ref: improve-windows-ico-generation flutter: uses-material-design: true + assets: + - assets/icon.png diff --git a/example/web/favicon.png b/example/web/favicon.png index 8aaa46a..080adb6 100644 Binary files a/example/web/favicon.png and b/example/web/favicon.png differ diff --git a/example/web/icons/Icon-192.png b/example/web/icons/Icon-192.png index b749bfe..9f765bd 100644 Binary files a/example/web/icons/Icon-192.png and b/example/web/icons/Icon-192.png differ diff --git a/example/web/icons/Icon-512.png b/example/web/icons/Icon-512.png index 88cfd48..8925dc9 100644 Binary files a/example/web/icons/Icon-512.png and b/example/web/icons/Icon-512.png differ diff --git a/example/web/icons/Icon-maskable-192.png b/example/web/icons/Icon-maskable-192.png index eb9b4d7..9f765bd 100644 Binary files a/example/web/icons/Icon-maskable-192.png and b/example/web/icons/Icon-maskable-192.png differ diff --git a/example/web/icons/Icon-maskable-512.png b/example/web/icons/Icon-maskable-512.png index d69c566..8925dc9 100644 Binary files a/example/web/icons/Icon-maskable-512.png and b/example/web/icons/Icon-maskable-512.png differ diff --git a/example/web/index.html b/example/web/index.html index 26f5da2..4ce3164 100644 --- a/example/web/index.html +++ b/example/web/index.html @@ -1,6 +1,4 @@ - - - + - + - + - example + Universal BLE - - - + + + + + + + + + diff --git a/example/web/manifest.json b/example/web/manifest.json index 8bc88df..d61d7d4 100644 --- a/example/web/manifest.json +++ b/example/web/manifest.json @@ -5,7 +5,7 @@ "display": "standalone", "background_color": "#0175C2", "theme_color": "#0175C2", - "description": "Example app for Web", + "description": "Universal BLE", "orientation": "portrait-primary", "prefer_related_applications": false, "icons": [ diff --git a/example/windows/CMakeLists.txt b/example/windows/CMakeLists.txt index 0937d82..5156bbb 100644 --- a/example/windows/CMakeLists.txt +++ b/example/windows/CMakeLists.txt @@ -4,7 +4,7 @@ project(universal_ble_example LANGUAGES CXX) # The name of the executable created for the application. Change this to change # the on-disk name of your application. -set(BINARY_NAME "universal_ble_example") +set(BINARY_NAME "universal_ble") # Explicitly opt in to modern CMake behaviors to avoid warnings with recent # versions of CMake. diff --git a/example/windows/runner/Runner.rc b/example/windows/runner/Runner.rc index 23c0d5c..522cec7 100644 --- a/example/windows/runner/Runner.rc +++ b/example/windows/runner/Runner.rc @@ -89,13 +89,13 @@ BEGIN BEGIN BLOCK "040904e4" BEGIN - VALUE "CompanyName", "com.navideck" "\0" - VALUE "FileDescription", "universal_ble_example" "\0" + VALUE "CompanyName", "Navideck" "\0" + VALUE "FileDescription", "Universal BLE" "\0" VALUE "FileVersion", VERSION_AS_STRING "\0" - VALUE "InternalName", "universal_ble_example" "\0" - VALUE "LegalCopyright", "Copyright (C) 2023 com.navideck. All rights reserved." "\0" - VALUE "OriginalFilename", "universal_ble_example.exe" "\0" - VALUE "ProductName", "universal_ble_example" "\0" + VALUE "InternalName", "Universal BLE" "\0" + VALUE "LegalCopyright", "Copyright © 2023 com.navideck. All rights reserved." "\0" + VALUE "OriginalFilename", "universal_ble.exe" "\0" + VALUE "ProductName", "Universal BLE" "\0" VALUE "ProductVersion", VERSION_AS_STRING "\0" END END diff --git a/example/windows/runner/main.cpp b/example/windows/runner/main.cpp index f2b1e45..5c8b4dd 100644 --- a/example/windows/runner/main.cpp +++ b/example/windows/runner/main.cpp @@ -27,7 +27,7 @@ int APIENTRY wWinMain(_In_ HINSTANCE instance, _In_opt_ HINSTANCE prev, FlutterWindow window(project); Win32Window::Point origin(10, 10); Win32Window::Size size(1280, 720); - if (!window.Create(L"universal_ble_example", origin, size)) { + if (!window.Create(L"Universal BLE", origin, size)) { return EXIT_FAILURE; } window.SetQuitOnClose(true); diff --git a/example/windows/runner/resources/app_icon.ico b/example/windows/runner/resources/app_icon.ico index c04e20c..27c6b4f 100644 Binary files a/example/windows/runner/resources/app_icon.ico and b/example/windows/runner/resources/app_icon.ico differ diff --git a/lib/src/models/manufacturer_data.dart b/lib/src/models/manufacturer_data.dart index c6a64ba..9c58c40 100644 --- a/lib/src/models/manufacturer_data.dart +++ b/lib/src/models/manufacturer_data.dart @@ -8,6 +8,9 @@ class ManufacturerData { String get companyIdRadix16 => "0x0${companyId.toRadixString(16)}"; + String get payloadRadix16 => + "0x${payload.map((e) => e.toRadixString(16).toUpperCase().padLeft(2, '0')).join('')}"; + factory ManufacturerData.fromData(Uint8List data) { if (data.length < 2) { throw const FormatException("Invalid Manufacturer Data");