Files
universal_ble/example/lib/home/scanner_screen.dart
T
2026-02-10 22:54:14 +01:00

1036 lines
38 KiB
Dart

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/prefs_async.dart';
import 'package:universal_ble_example/data/saved_scan_filter.dart';
import 'package:universal_ble_example/data/company_identifier_service.dart';
import 'package:universal_ble_example/data/mock_universal_ble.dart';
import 'package:universal_ble_example/data/scan_controller.dart';
import 'package:universal_ble_example/home/widgets/drawer.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/scan_controller_scope.dart';
enum ScanOrder {
lastSeen,
name,
rssi,
}
class ScannerScreen extends StatefulWidget {
const ScannerScreen({super.key});
@override
State createState() => _ScannerScreenState();
}
class _ScannerScreenState extends State<ScannerScreen> {
final _bleDevices = <BleDevice>[];
final _hiddenDevices = <BleDevice>[];
ScanController? _scanController;
void _onScanStateChanged() => setState(() {});
QueueType _queueType = QueueType.global;
TextEditingController servicesFilterController = TextEditingController();
TextEditingController namePrefixController = TextEditingController();
TextEditingController manufacturerDataController = TextEditingController();
final TextEditingController _searchFilterController = TextEditingController();
final TextEditingController _webServicesController = TextEditingController();
StreamSubscription<BleDevice>? _scanSubscription;
AvailabilityState? bleAvailabilityState;
ScanFilter? scanFilter;
List<SavedScanFilter> _savedFilters = [];
final ValueNotifier<List<SavedScanFilter>> _savedFiltersNotifier =
ValueNotifier([]);
ScanOrder _scanOrder = ScanOrder.rssi;
final Map<String, bool> _isExpanded = {};
final Map<String, int> _deviceAdFlashTrigger = {};
@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);
// Get initial Bluetooth availability state
UniversalBle.getBluetoothAvailabilityState().then((state) {
if (mounted) {
setState(() => bleAvailabilityState = state);
}
});
_loadScanFilters().then((_) {
// Auto-start scanning after filters are loaded
_tryAutoStartScan();
});
// Load company identifiers in the background
CompanyIdentifierService.instance.load();
// Save search filter when it changes
_searchFilterController.addListener(() {
_saveScanFilters();
});
}
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;
}
_deviceAdFlashTrigger[result.deviceId] =
(_deviceAdFlashTrigger[result.deviceId] ?? 0) + 1;
setState(() {});
}
Future<void> _startScan() async {
setState(() => _bleDevices.clear());
try {
await _scanController?.startScan();
} catch (e) {
showSnackbar(e.toString());
}
}
Future<void> _loadScanFilters() async {
try {
final services =
(await prefsAsync.getString('scan_filter_services')) ?? '';
final namePrefix =
(await prefsAsync.getString('scan_filter_name_prefix')) ?? '';
final manufacturerData =
(await prefsAsync.getString('scan_filter_manufacturer_data')) ?? '';
final searchFilter =
(await prefsAsync.getString('scan_filter_search')) ?? '';
final webServices =
(await prefsAsync.getString('scan_filter_web_services')) ?? '';
final scanOrderStr = await prefsAsync.getString('scan_order');
final savedFiltersJson =
await prefsAsync.getString('saved_scan_filters');
_savedFilters = SavedScanFilter.fromJsonList(savedFiltersJson);
_savedFiltersNotifier.value = List.from(_savedFilters);
servicesFilterController.text = services;
namePrefixController.text = namePrefix;
manufacturerDataController.text = manufacturerData;
_searchFilterController.text = searchFilter;
_webServicesController.text = webServices;
if (scanOrderStr != null) {
switch (scanOrderStr) {
case 'lastSeen':
_scanOrder = ScanOrder.lastSeen;
break;
case 'name':
_scanOrder = ScanOrder.name;
break;
case 'rssi':
_scanOrder = ScanOrder.rssi;
break;
}
if (mounted) setState(() {});
}
// Reconstruct the scan filter if any values exist
if (services.isNotEmpty ||
namePrefix.isNotEmpty ||
manufacturerData.isNotEmpty) {
_applyFilterFromControllers();
}
} catch (e) {
// Silently fail if preferences can't be loaded
debugPrint('Failed to load scan filters: $e');
}
}
Future<void> _saveSavedFilters() async {
try {
await prefsAsync.setString(
'saved_scan_filters',
SavedScanFilter.toJsonList(_savedFilters),
);
} catch (e) {
debugPrint('Failed to save saved filters: $e');
}
}
Future<void> _saveScanFilters() async {
try {
await prefsAsync.setString(
'scan_filter_services', servicesFilterController.text);
await prefsAsync.setString(
'scan_filter_name_prefix', namePrefixController.text);
await prefsAsync.setString(
'scan_filter_manufacturer_data', manufacturerDataController.text);
await prefsAsync.setString(
'scan_filter_search', _searchFilterController.text);
await prefsAsync.setString(
'scan_filter_web_services', _webServicesController.text);
await prefsAsync.setString('scan_order', _scanOrder.name);
} catch (e) {
debugPrint('Failed to save scan filters: $e');
}
}
Future<void> _applyFilterFromControllers() async {
try {
// Ensure company identifier service is loaded
await CompanyIdentifierService.instance.load();
List<String> serviceUUids = [];
List<String> namePrefixes = [];
List<ManufacturerDataFilter> manufacturerDataFilters = [];
// Parse Services
if (servicesFilterController.text.isNotEmpty) {
List<String> services = servicesFilterController.text
.split(',')
.map((e) => e.trim())
.where((s) => s.isNotEmpty)
.toList();
for (String service in services) {
try {
serviceUUids.add(BleUuidParser.string(service.trim()));
} on FormatException catch (_) {
// Skip invalid service UUIDs when loading from preferences
continue;
}
}
}
// Parse Name Prefix
String namePrefix = namePrefixController.text;
if (namePrefix.isNotEmpty) {
namePrefixes = namePrefix.split(',').map((e) => e.trim()).toList();
}
// Parse Manufacturer Data
String manufacturerDataText = manufacturerDataController.text;
if (manufacturerDataText.isNotEmpty) {
final companyService = CompanyIdentifierService.instance;
List<String> manufacturerData = manufacturerDataText
.split(',')
.map((e) => e.trim())
.where((s) => s.isNotEmpty)
.toList();
for (String manufacturer in manufacturerData) {
final companyIdentifier =
companyService.parseCompanyIdentifier(manufacturer);
if (companyIdentifier == null) {
// Skip invalid manufacturer data when loading from preferences
continue;
}
manufacturerDataFilters.add(
ManufacturerDataFilter(companyIdentifier: companyIdentifier));
}
}
if (serviceUUids.isEmpty &&
namePrefixes.isEmpty &&
manufacturerDataFilters.isEmpty) {
scanFilter = null;
} else {
scanFilter = ScanFilter(
withServices: serviceUUids,
withNamePrefix: namePrefixes,
withManufacturerData: manufacturerDataFilters,
);
}
setState(() {});
} catch (e) {
// Silently fail if filter can't be applied
debugPrint('Failed to apply filter from controllers: $e');
}
}
Future<void> _onScanFilterApplied(ScanFilter? filter) async {
setState(() {
scanFilter = filter;
_bleDevices.clear();
_hiddenDevices.clear();
_deviceAdFlashTrigger.clear();
});
await _saveScanFilters();
// Store applied filter as a saved preset (keep last 10), unless it already matches one
final servicesText = servicesFilterController.text;
final namePrefixText = namePrefixController.text;
final manufacturerDataText = manufacturerDataController.text;
final hasValues = servicesText.trim().isNotEmpty ||
namePrefixText.trim().isNotEmpty ||
manufacturerDataText.trim().isNotEmpty;
if (hasValues) {
final alreadyStored = _savedFilters.any((f) =>
f.services == servicesText &&
f.namePrefix == namePrefixText &&
f.manufacturerData == manufacturerDataText);
if (!alreadyStored) {
final name = 'Filter ${_savedFilters.length + 1}';
_savedFilters.add(SavedScanFilter(
name: name,
services: servicesText,
namePrefix: namePrefixText,
manufacturerData: manufacturerDataText,
));
if (_savedFilters.length > 10) {
_savedFilters = _savedFilters.sublist(_savedFilters.length - 10);
}
await _saveSavedFilters();
if (mounted) {
setState(() {});
_savedFiltersNotifier.value = List.from(_savedFilters);
}
}
}
if (_scanController?.isScanning == true) {
await _scanController?.stopScan();
if (!mounted) return;
}
if (mounted) await _startScan();
}
int _getActiveFilterCount(ScanFilter? filter) {
if (filter == null) return 0;
int n = 0;
if (filter.withServices.isNotEmpty) n++;
if (filter.withNamePrefix.isNotEmpty) n++;
if (filter.withManufacturerData.isNotEmpty) n++;
return n;
}
Future<void> _onSelectSavedFilter(SavedScanFilter preset) async {
servicesFilterController.text = preset.services;
namePrefixController.text = preset.namePrefix;
manufacturerDataController.text = preset.manufacturerData;
await _applyFilterFromControllers();
await _saveScanFilters();
if (_scanController?.isScanning == true) {
await _scanController?.stopScan();
if (!mounted) return;
}
if (mounted) {
await _startScan();
setState(() {});
}
}
Future<void> _onRenameFilter(SavedScanFilter preset, String newName) async {
final i = _savedFilters.indexWhere(
(f) =>
f.services == preset.services &&
f.namePrefix == preset.namePrefix &&
f.manufacturerData == preset.manufacturerData,
);
if (i >= 0) {
_savedFilters[i] = _savedFilters[i].copyWith(name: newName);
await _saveSavedFilters();
if (mounted) {
setState(() {});
_savedFiltersNotifier.value = List.from(_savedFilters);
}
}
}
Future<void> _onDeleteFilter(SavedScanFilter preset) async {
_savedFilters.removeWhere(
(f) =>
f.name == preset.name &&
f.services == preset.services &&
f.namePrefix == preset.namePrefix &&
f.manufacturerData == preset.manufacturerData,
);
await _saveSavedFilters();
if (mounted) {
setState(() {});
_savedFiltersNotifier.value = List.from(_savedFilters);
}
}
void _showScanFilterBottomSheet() {
showModalBottomSheet(
isScrollControlled: true,
context: context,
builder: (context) {
return ScanFilterWidget(
servicesFilterController: servicesFilterController,
namePrefixController: namePrefixController,
manufacturerDataController: manufacturerDataController,
savedFiltersListenable: _savedFiltersNotifier,
onScanFilter: (ScanFilter? filter) {
_onScanFilterApplied(filter);
},
onSelectSavedFilter: _onSelectSavedFilter,
onRenameFilter: _onRenameFilter,
onDeleteFilter: _onDeleteFilter,
);
},
);
}
void showSnackbar(String message) {
ScaffoldMessenger.of(
context,
).showSnackBar(SnackBar(content: Text(message)));
}
List<BleDevice> get _filteredDevices {
if (_searchFilterController.text.isEmpty) {
return _bleDevices;
}
final filter = _searchFilterController.text.toLowerCase();
final companyService = CompanyIdentifierService.instance;
// Try to parse the filter as a company ID (supports hex and decimal formats)
final parsedCompanyId =
companyService.parseCompanyIdentifier(_searchFilterController.text);
return _bleDevices.where((device) {
final name = device.name?.toLowerCase() ?? '';
final deviceId = device.deviceId.toLowerCase();
final services = device.services.join(' ').toLowerCase();
// Check if filter matches device name, ID, or services
if (name.contains(filter) ||
deviceId.contains(filter) ||
services.contains(filter)) {
return true;
}
// Check if filter matches any company name or company ID from manufacturer data
for (final manufacturerData in device.manufacturerDataList) {
// Check company name match
final companyName =
companyService.getCompanyName(manufacturerData.companyId);
if (companyName != null && companyName.toLowerCase().contains(filter)) {
return true;
}
// Check company ID match (if filter was parsed as a company ID)
if (parsedCompanyId != null &&
manufacturerData.companyId == parsedCompanyId) {
return true;
}
// Also check if filter matches the hex representation of company ID
final companyIdHex = manufacturerData.companyIdRadix16.toLowerCase();
if (companyIdHex.contains(filter) ||
companyIdHex.replaceAll('0x', '').contains(filter)) {
return true;
}
}
return false;
}).toList();
}
List<BleDevice> get _sortedDevices {
final list = List<BleDevice>.from(_filteredDevices);
switch (_scanOrder) {
case ScanOrder.lastSeen:
list.sort((a, b) {
final ta = a.timestamp ?? 0;
final tb = b.timestamp ?? 0;
return tb.compareTo(ta); // newest first
});
break;
case ScanOrder.name:
list.sort((a, b) {
final na = (a.name ?? '').toLowerCase();
final nb = (b.name ?? '').toLowerCase();
if (na.isEmpty && nb.isEmpty) return 0;
if (na.isEmpty) return 1;
if (nb.isEmpty) return -1;
return na.compareTo(nb);
});
break;
case ScanOrder.rssi:
list.sort((a, b) {
final ra = a.rssi ?? -999;
final rb = b.rssi ?? -999;
return rb.compareTo(ra); // strongest first
});
break;
}
return list;
}
bool get _isBluetoothAvailable =>
bleAvailabilityState == AvailabilityState.poweredOn;
Future<void> _tryAutoStartScan() async {
// Only auto-start if Bluetooth is available and not already scanning
if (_isBluetoothAvailable && _scanController?.isScanning != true) {
final isScanning = await UniversalBle.isScanning();
if (!isScanning && mounted) {
await _startScan();
}
}
}
@override
void setState(VoidCallback fn) {
if (mounted) super.setState(fn);
}
@override
void didChangeDependencies() {
super.didChangeDependencies();
_scanController ??= ScanControllerScope.of(context)
..addListener(_onScanStateChanged);
}
@override
void dispose() {
_scanController?.removeListener(_onScanStateChanged);
servicesFilterController.dispose();
namePrefixController.dispose();
manufacturerDataController.dispose();
_searchFilterController.dispose();
_webServicesController.dispose();
_savedFiltersNotifier.dispose();
_scanSubscription?.cancel();
super.dispose();
}
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final colorScheme = theme.colorScheme;
return Scaffold(
backgroundColor: colorScheme.surface,
drawer: AppDrawer(
queueType: _queueType,
onQueueTypeChanged: (queueType) {
setState(() {
_queueType = queueType;
UniversalBle.queueType = _queueType;
});
},
availabilityState: bleAvailabilityState,
onAvailabilityStateChanged: (state) {
setState(() => bleAvailabilityState = state);
if (state == AvailabilityState.poweredOn) {
_tryAutoStartScan();
}
},
),
appBar: AppBar(
title: Row(
children: [
Expanded(
child: Container(
height: 40,
decoration: BoxDecoration(
color: colorScheme.surfaceContainerHighest,
borderRadius: BorderRadius.circular(8),
),
child: TextField(
controller: _searchFilterController,
decoration: InputDecoration(
hintText: 'Search by name, ID, service, company name/ID',
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();
_saveScanFilters();
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(() {}),
),
),
),
],
),
elevation: 0,
backgroundColor: colorScheme.surface,
leading: Builder(
builder: (context) => IconButton(
icon: const Icon(Icons.menu),
onPressed: () => Scaffold.of(context).openDrawer(),
),
),
actions: [
Padding(
padding: const EdgeInsets.symmetric(horizontal: 8.0, vertical: 4.0),
child: FilledButton.icon(
onPressed: _isBluetoothAvailable && _scanController != null
? () async {
if (_scanController!.isScanning) {
await _scanController!.stopScan();
} else {
await _startScan();
}
}
: null,
icon: Icon(
_scanController?.isScanning == true
? Icons.stop_circle
: Icons.play_arrow,
size: 20,
),
label: Text(
_scanController?.isScanning == true ? 'Stop' : 'Scan',
style: const TextStyle(
fontSize: 14,
fontWeight: FontWeight.w600,
),
),
style: FilledButton.styleFrom(
backgroundColor:
_scanController?.isScanning == true
? colorScheme.error
: colorScheme.primary,
foregroundColor:
_scanController?.isScanning == true
? colorScheme.onError
: colorScheme.onPrimary,
padding: const EdgeInsets.symmetric(
horizontal: 12,
vertical: 12,
),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(8),
),
elevation: _scanController?.isScanning == true ? 0 : 2,
),
),
),
],
),
body: Column(
children: [
// 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 SizedBox(width: 4),
Badge(
isLabelVisible: _getActiveFilterCount(scanFilter) > 0,
label: Text(
'${_getActiveFilterCount(scanFilter)}',
style: const TextStyle(
fontSize: 10,
fontWeight: FontWeight.w600,
),
),
child: 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 Spacer(),
PopupMenuButton<ScanOrder>(
tooltip: 'Sort by',
padding: EdgeInsets.zero,
onSelected: (ScanOrder order) {
setState(() {
_scanOrder = order;
});
_saveScanFilters();
},
itemBuilder: (context) => [
const PopupMenuItem(
value: ScanOrder.rssi,
child: Text('RSSI'),
),
const PopupMenuItem(
value: ScanOrder.lastSeen,
child: Text('Last seen'),
),
const PopupMenuItem(
value: ScanOrder.name,
child: Text('Name'),
),
],
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 8),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Text(
_scanOrder == ScanOrder.lastSeen
? 'Last seen'
: _scanOrder == ScanOrder.name
? 'Name'
: 'RSSI',
style: TextStyle(
color: colorScheme.onSurface,
fontSize: 12,
fontWeight: FontWeight.w600,
),
),
Icon(
Icons.arrow_drop_down,
size: 18,
color: colorScheme.onSurface,
),
],
),
),
),
],
),
),
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,
),
),
]
],
),
),
)
: _scanController?.isScanning == true && _bleDevices.isEmpty
? Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
CircularProgressIndicator.adaptive(
valueColor: AlwaysStoppedAnimation<Color>(
colorScheme.primary,
),
),
const SizedBox(height: 16),
Text(
'Scanning for devices...',
style: TextStyle(
color: colorScheme.onSurface
.withValues(alpha: 0.6),
fontSize: 14,
),
),
],
),
)
: _scanController?.isScanning != true && _bleDevices.isEmpty
? ScannedDevicesPlaceholderWidget(onTap: _startScan)
: _sortedDevices.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: _sortedDevices.length,
separatorBuilder: (context, index) =>
const SizedBox(height: 8),
itemBuilder: (context, index) {
BleDevice device = _sortedDevices[index];
return ScannedItemWidget(
bleDevice: device,
adFlashTrigger:
_deviceAdFlashTrigger[device.deviceId] ??
0,
isExpanded:
_isExpanded[device.deviceId] ?? false,
onExpand: (isExpanded) {
setState(() {
_isExpanded[device.deviceId] =
isExpanded;
});
},
onTap: () {
Navigator.push(
context,
MaterialPageRoute(
builder: (_) =>
PeripheralDetailPage(device),
),
);
},
);
},
),
),
],
),
);
}
}