062f6c4c8c
- Introduced a new method `_getActiveFilterCount` to calculate the number of active filters. - Updated the filter button to show a badge with the active filter count. - Changed the clear filter button icon for better clarity. - Enhanced the hint style in the scan filter widget for improved visibility.
932 lines
35 KiB
Dart
932 lines
35 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/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;
|
|
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');
|
|
|
|
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> _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');
|
|
}
|
|
}
|
|
|
|
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();
|
|
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;
|
|
}
|
|
|
|
void _showScanFilterBottomSheet() {
|
|
showModalBottomSheet(
|
|
isScrollControlled: true,
|
|
context: context,
|
|
builder: (context) {
|
|
return ScanFilterWidget(
|
|
servicesFilterController: servicesFilterController,
|
|
namePrefixController: namePrefixController,
|
|
manufacturerDataController: manufacturerDataController,
|
|
onScanFilter: (ScanFilter? filter) {
|
|
_onScanFilterApplied(filter);
|
|
},
|
|
);
|
|
},
|
|
);
|
|
}
|
|
|
|
void showSnackbar(String message) {
|
|
ScaffoldMessenger.of(
|
|
context,
|
|
).showSnackBar(SnackBar(content: Text(message)));
|
|
}
|
|
|
|
List<BleDevice> get _filteredDevices {
|
|
if (_searchFilterController.text.isEmpty) {
|
|
return _bleDevices;
|
|
}
|
|
final filter = _searchFilterController.text.toLowerCase();
|
|
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();
|
|
|
|
_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),
|
|
),
|
|
);
|
|
},
|
|
);
|
|
},
|
|
),
|
|
),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
}
|