Files
universal_ble/example/lib/home/scanner_screen.dart
T
Foti Dim 1334057a8a Show and filter by company name (#208)
* Update company identifier handling and enhance scanning features

- Updated `pubspec.lock` to version 1.2.0 and added `yaml` dependency.
- Introduced `CompanyIdentifierService` for loading and querying company identifiers from a new YAML file.
- Enhanced scanning functionality to support filtering by company names and IDs.
- Updated UI components to display company names alongside manufacturer data in scanned items.
- Improved search functionality to include company names in the filter criteria.

* Address AI comments

* Address AI comments

* Allow searching by company id

* Remove redundant loading state update in CompanyIdentifierService

* Enhance company ID parsing in CompanyIdentifierService

- Updated the `_parseCompanyId` method to support additional formats for company IDs, including non-prefixed strings interpreted as decimal or hexadecimal based on their content.
- Improved parsing logic to first attempt hexadecimal conversion if the string contains hex characters, enhancing flexibility in input handling.
2026-01-19 05:35:19 +01:00

829 lines
31 KiB
Dart

import 'dart:async';
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:shared_preferences/shared_preferences.dart';
import 'package:universal_ble/universal_ble.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/home/widgets/ble_availability_icon.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';
class ScannerScreen extends StatefulWidget {
const ScannerScreen({super.key});
@override
State createState() => _ScannerScreenState();
}
class _ScannerScreenState extends State<ScannerScreen> {
final _bleDevices = <BleDevice>[];
final _hiddenDevices = <BleDevice>[];
bool _isScanning = false;
QueueType _queueType = QueueType.global;
TextEditingController servicesFilterController = TextEditingController();
TextEditingController namePrefixController = TextEditingController();
TextEditingController manufacturerDataController = TextEditingController();
final TextEditingController _searchFilterController = TextEditingController();
final TextEditingController _webServicesController = TextEditingController();
StreamSubscription<BleDevice>? _scanSubscription;
AvailabilityState? bleAvailabilityState;
ScanFilter? scanFilter;
final Map<String, bool> _isExpanded = {};
@override
void initState() {
super.initState();
if (const bool.fromEnvironment('MOCK')) {
UniversalBle.setInstance(MockUniversalBle());
}
UniversalBle.queueType = _queueType;
UniversalBle.timeout = const Duration(seconds: 10);
_scanSubscription = UniversalBle.scanStream.listen(_handleScanResult);
UniversalBle.isScanning().then(
(isScanning) => setState(() => _isScanning = isScanning),
);
_loadScanFilters();
// 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;
}
setState(() {});
}
Future<void> _startScan() async {
setState(() {
_bleDevices.clear();
_isScanning = true;
});
try {
PlatformConfig? platformConfig;
if (kIsWeb && _webServicesController.text.isNotEmpty) {
List<String> webServices = _webServicesController.text
.split(',')
.where((s) => s.trim().isNotEmpty)
.map((s) {
try {
return BleUuidParser.string(s.trim());
} catch (_) {
return s.trim();
}
}).toList();
platformConfig = PlatformConfig(
web: WebOptions(optionalServices: webServices),
);
}
await UniversalBle.startScan(
scanFilter: scanFilter,
platformConfig: platformConfig,
);
} catch (e) {
setState(() {
_isScanning = false;
});
showSnackbar(e.toString());
}
}
Future<void> _loadScanFilters() async {
try {
final prefs = await SharedPreferences.getInstance();
final services = prefs.getString('scan_filter_services') ?? '';
final namePrefix = prefs.getString('scan_filter_name_prefix') ?? '';
final manufacturerData =
prefs.getString('scan_filter_manufacturer_data') ?? '';
final searchFilter = prefs.getString('scan_filter_search') ?? '';
servicesFilterController.text = services;
namePrefixController.text = namePrefix;
manufacturerDataController.text = manufacturerData;
_searchFilterController.text = searchFilter;
// 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 {
final prefs = await SharedPreferences.getInstance();
await prefs.setString(
'scan_filter_services', servicesFilterController.text);
await prefs.setString(
'scan_filter_name_prefix', namePrefixController.text);
await prefs.setString(
'scan_filter_manufacturer_data', manufacturerDataController.text);
await prefs.setString('scan_filter_search', _searchFilterController.text);
} 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');
}
}
void _showScanFilterBottomSheet() {
showModalBottomSheet(
isScrollControlled: true,
context: context,
builder: (context) {
return ScanFilterWidget(
servicesFilterController: servicesFilterController,
namePrefixController: namePrefixController,
manufacturerDataController: manufacturerDataController,
onScanFilter: (ScanFilter? filter) {
setState(() {
scanFilter = filter;
});
_saveScanFilters();
},
);
},
);
}
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();
}
bool get _isBluetoothAvailable =>
bleAvailabilityState == AvailabilityState.poweredOn;
String _getBluetoothAvailabilityTooltip() {
switch (bleAvailabilityState) {
case AvailabilityState.poweredOn:
return 'Bluetooth is on';
case AvailabilityState.poweredOff:
return 'Bluetooth is off';
case AvailabilityState.resetting:
return 'Bluetooth is resetting';
case AvailabilityState.unauthorized:
return 'Bluetooth permission denied';
case AvailabilityState.unsupported:
return 'Bluetooth not supported';
case AvailabilityState.unknown:
return 'Bluetooth status unknown';
case null:
return 'Checking Bluetooth status...';
}
}
@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();
}
@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;
});
},
),
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(() {}),
),
),
),
const SizedBox(width: 12),
Tooltip(
message: _getBluetoothAvailabilityTooltip(),
triggerMode: TooltipTriggerMode.tap,
child: BleAvailabilityIcon(onAvailabilityStateChanged: (state) {
setState(() => bleAvailabilityState = state);
}),
),
],
),
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
? () async {
if (_isScanning) {
await UniversalBle.stopScan();
setState(() {
_isScanning = false;
});
} else {
await _startScan();
}
}
: null,
icon: Icon(
_isScanning ? Icons.stop_circle : Icons.play_arrow,
size: 20,
),
label: Text(
_isScanning ? 'Stop Scan' : 'Start Scan',
style: const TextStyle(
fontSize: 14,
fontWeight: FontWeight.w600,
),
),
style: FilledButton.styleFrom(
backgroundColor:
_isScanning ? colorScheme.error : colorScheme.primary,
foregroundColor:
_isScanning ? colorScheme.onError : colorScheme.onPrimary,
padding: const EdgeInsets.symmetric(
horizontal: 16,
vertical: 12,
),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(8),
),
elevation: _isScanning ? 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 Spacer(),
TextButton.icon(
onPressed: _showScanFilterBottomSheet,
icon: Icon(
scanFilter != null
? Icons.filter_list
: Icons.filter_list_outlined,
size: 18,
color: scanFilter != null
? colorScheme.primary
: colorScheme.onSurface,
),
label: Text(
'Filter',
style: TextStyle(
color: scanFilter != null
? colorScheme.primary
: colorScheme.onSurface,
fontSize: 12,
fontWeight: FontWeight.w600,
),
),
style: TextButton.styleFrom(
padding: EdgeInsets.symmetric(horizontal: 8),
backgroundColor: scanFilter != null
? colorScheme.primaryContainer
: null,
),
),
const SizedBox(width: 4),
TextButton.icon(
onPressed: () {
setState(() {
_bleDevices.clear();
});
},
icon: Icon(
Icons.clear_all,
size: 18,
color: colorScheme.error,
),
label: Text(
'Clear',
style: TextStyle(
color: colorScheme.error,
fontSize: 12,
fontWeight: FontWeight.w600,
),
),
style: TextButton.styleFrom(
padding: EdgeInsets.symmetric(horizontal: 8),
),
),
],
),
),
const Divider(),
// Devices List
Expanded(
child: !_isBluetoothAvailable
? Center(
child: Padding(
padding: const EdgeInsets.all(24.0),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Container(
padding: const EdgeInsets.all(32),
decoration: BoxDecoration(
color: colorScheme.errorContainer.withValues(
alpha: 0.3,
),
shape: BoxShape.circle,
),
child: Icon(
Icons.bluetooth_disabled,
size: 80,
color: colorScheme.error,
),
),
const SizedBox(height: 32),
Text(
'Bluetooth is Turned Off',
style: TextStyle(
fontSize: 24,
fontWeight: FontWeight.bold,
color: colorScheme.onSurface,
),
textAlign: TextAlign.center,
),
const SizedBox(height: 16),
Text(
'Please turn on Bluetooth to scan for devices',
style: TextStyle(
fontSize: 16,
color: colorScheme.onSurface.withValues(
alpha: 0.7,
),
),
textAlign: TextAlign.center,
),
if (BleCapabilities.supportsBluetoothEnableApi) ...[
const SizedBox(height: 48),
ElevatedButton.icon(
onPressed: () async {
try {
bool isEnabled =
await UniversalBle.enableBluetooth();
if (!isEnabled) {
showSnackbar(
"Please enable Bluetooth in system settings",
);
}
} catch (e) {
showSnackbar(e.toString());
}
},
icon: const Icon(Icons.bluetooth, size: 24),
label: const Text(
'Turn On Bluetooth',
style: TextStyle(fontSize: 18),
),
style: ElevatedButton.styleFrom(
backgroundColor: colorScheme.primary,
foregroundColor: colorScheme.onPrimary,
padding: const EdgeInsets.symmetric(
horizontal: 32,
vertical: 16,
),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12),
),
elevation: 2,
),
),
]
],
),
),
)
: _isScanning && _bleDevices.isEmpty
? Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
CircularProgressIndicator.adaptive(
valueColor: AlwaysStoppedAnimation<Color>(
colorScheme.primary,
),
),
const SizedBox(height: 16),
Text(
'Scanning for devices...',
style: TextStyle(
color: colorScheme.onSurface
.withValues(alpha: 0.6),
fontSize: 14,
),
),
],
),
)
: !_isScanning && _bleDevices.isEmpty
? ScannedDevicesPlaceholderWidget(onTap: _startScan)
: _filteredDevices.isEmpty
? Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(
Icons.search_off,
size: 64,
color: colorScheme.onSurface
.withValues(alpha: 0.3),
),
const SizedBox(height: 16),
Text(
'No devices match your filter',
style: TextStyle(
color: colorScheme.onSurface
.withValues(alpha: 0.6),
fontSize: 16,
),
),
],
),
)
: ListView.separated(
padding: const EdgeInsets.symmetric(
horizontal: 16,
vertical: 8,
),
itemCount: _filteredDevices.length,
separatorBuilder: (context, index) =>
const SizedBox(height: 8),
itemBuilder: (context, index) {
BleDevice device = _filteredDevices[
_filteredDevices.length - index - 1];
return ScannedItemWidget(
bleDevice: device,
isExpanded:
_isExpanded[device.deviceId] ?? false,
onExpand: (isExpanded) {
setState(() {
_isExpanded[device.deviceId] =
isExpanded;
});
},
onTap: () {
Navigator.push(
context,
MaterialPageRoute(
builder: (_) =>
PeripheralDetailPage(device),
),
);
// Stop scan but keep results visible
UniversalBle.stopScan();
setState(() {
_isScanning = false;
});
},
);
},
),
),
],
),
);
}
}