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.
This commit is contained in:
Foti Dim
2026-01-19 05:35:19 +01:00
committed by GitHub
parent bcfb0adc6a
commit 1334057a8a
10 changed files with 12249 additions and 50 deletions
+2
View File
@@ -4,6 +4,8 @@
* Moved search field to app bar header for better accessibility
* Moved queue type settings to drawer menu as expandable section
* Added tooltip to Bluetooth availability icon (tap to view on mobile)
* Display company name based on company identifier from manufacturer data
* Enhanced search functionality - now supports searching by company name
* Improved overall UI layout and navigation flow
## 1.1.0
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,278 @@
import 'package:flutter/foundation.dart';
import 'package:flutter/services.dart';
import 'package:yaml/yaml.dart';
/// Service for loading and querying company identifiers from YAML file
class CompanyIdentifierService {
static CompanyIdentifierService? _instance;
static CompanyIdentifierService get instance {
_instance ??= CompanyIdentifierService._();
return _instance!;
}
CompanyIdentifierService._();
final Map<int, String> _companyIdToName = {};
final Map<String, int> _companyNameToId = {};
bool _isLoading = false;
bool _isLoaded = false;
bool _hasWarnedAboutNotLoaded = false;
/// Parse company ID from a value (String hex or int)
/// Supports formats: "0x1053", "0x004C", "1053" (decimal), "4C" (hex), or integer value
/// Non-prefixed strings are parsed as decimal if they contain only digits,
/// or as hexadecimal if they contain hex characters (a-fA-F)
int? _parseCompanyId(dynamic value) {
if (value is String) {
final trimmed = value.trim();
if (trimmed.toLowerCase().startsWith('0x')) {
return 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]'))) {
return int.tryParse(trimmed, radix: 16);
} else {
return int.tryParse(trimmed);
}
}
} else if (value is int) {
return value;
}
return null;
}
/// Load company identifiers from YAML file
///
/// **Important:** This method should be called on app startup before using
/// any query methods (e.g., [getCompanyName], [getCompanyIdFromName], etc.).
/// If query methods are called before this method completes, they will return
/// null/empty values and log a warning.
Future<void> load() async {
if (_isLoaded || _isLoading) return;
_isLoading = true;
try {
final String yamlString =
await rootBundle.loadString('assets/company_identifiers.yaml');
final YamlMap yaml = loadYaml(yamlString) as YamlMap;
final YamlList? companyIdentifiers =
yaml['company_identifiers'] as YamlList?;
if (companyIdentifiers == null) {
_companyIdToName.clear();
_companyNameToId.clear();
_isLoaded = true;
return;
}
_companyIdToName.clear();
_companyNameToId.clear();
for (var entry in companyIdentifiers) {
if (entry is! YamlMap) continue;
final value = entry['value'];
final name = entry['name'];
if (value == null || name == null) continue;
final companyId = _parseCompanyId(value);
if (companyId != null && name is String) {
_companyIdToName[companyId] = name;
// Store case-insensitive lookup for company names
_companyNameToId[name.toLowerCase()] = companyId;
}
}
// Reset warning flag after successful load
_hasWarnedAboutNotLoaded = false;
_isLoaded = true;
} on PlatformException catch (e) {
// Handle file loading errors (e.g., file not found, permission issues)
debugPrint(
'CompanyIdentifierService: Failed to load company identifiers file: ${e.message}',
);
_companyIdToName.clear();
_companyNameToId.clear();
_isLoaded = true;
} on YamlException catch (e) {
// Handle YAML parsing errors (e.g., invalid YAML syntax)
debugPrint(
'CompanyIdentifierService: Failed to parse YAML: ${e.message}',
);
_companyIdToName.clear();
_companyNameToId.clear();
_isLoaded = true;
} catch (e, stackTrace) {
// Handle any other unexpected errors
debugPrint(
'CompanyIdentifierService: Unexpected error loading company identifiers: $e',
);
debugPrint('Stack trace: $stackTrace');
_companyIdToName.clear();
_companyNameToId.clear();
_isLoaded = true;
} finally {
_isLoading = false;
}
}
/// Get company name from company ID
///
/// Returns the company name for the given [companyId], or `null` if:
/// - The company ID is not found in the loaded data
/// - The data has not been loaded yet (see [load] method)
///
/// **Important:** The [load] method must be called on app startup before
/// using this method. If called before data is loaded, this method will
/// return `null` and log a warning (only once).
String? getCompanyName(int companyId) {
if (!_isLoaded) {
if (!_hasWarnedAboutNotLoaded) {
_hasWarnedAboutNotLoaded = true;
debugPrint(
'CompanyIdentifierService: getCompanyName called before load() completed. '
'Company names will not be available until load() is called on app startup.',
);
}
return null;
}
return _companyIdToName[companyId];
}
/// Get company name from hex string (e.g., "0x1053" or "0x004C")
String? getCompanyNameFromHex(String hexString) {
final companyId = _parseCompanyId(hexString);
if (companyId == null) return null;
return getCompanyName(companyId);
}
/// Get company ID from company name (case-insensitive)
///
/// Returns the company ID for the given [companyName], or `null` if:
/// - The company name is not found in the loaded data
/// - The data has not been loaded yet (see [load] method)
///
/// **Important:** The [load] method must be called on app startup before
/// using this method. If called before data is loaded, this method will
/// return `null` and log a warning (only once).
int? getCompanyIdFromName(String companyName) {
if (!_isLoaded) {
if (!_hasWarnedAboutNotLoaded) {
_hasWarnedAboutNotLoaded = true;
debugPrint(
'CompanyIdentifierService: getCompanyIdFromName called before load() completed. '
'Company identifiers will not be available until load() is called on app startup.',
);
}
return null;
}
return _companyNameToId[companyName.toLowerCase()];
}
/// Get all company names (for filtering/searching)
///
/// Returns a list of all company names, or an empty list if:
/// - No company identifiers are loaded
/// - The data has not been loaded yet (see [load] method)
///
/// **Important:** The [load] method must be called on app startup before
/// using this method. If called before data is loaded, this method will
/// return an empty list and log a warning (only once).
List<String> getAllCompanyNames() {
if (!_isLoaded) {
if (!_hasWarnedAboutNotLoaded) {
_hasWarnedAboutNotLoaded = true;
debugPrint(
'CompanyIdentifierService: getAllCompanyNames called before load() completed. '
'Company names will not be available until load() is called on app startup.',
);
}
return [];
}
return _companyIdToName.values.toList();
}
/// Check if a string matches a company name (case-insensitive partial match)
///
/// Returns `false` if:
/// - No match is found
/// - The data has not been loaded yet (see [load] method)
///
/// **Important:** The [load] method must be called on app startup before
/// using this method. If called before data is loaded, this method will
/// return `false` and log a warning (only once).
bool matchesCompanyName(String query) {
if (!_isLoaded) {
if (!_hasWarnedAboutNotLoaded) {
_hasWarnedAboutNotLoaded = true;
debugPrint(
'CompanyIdentifierService: matchesCompanyName called before load() completed. '
'Company name matching will not be available until load() is called on app startup.',
);
}
return false;
}
final lowerQuery = query.toLowerCase();
return _companyNameToId.keys.any((name) => name.contains(lowerQuery));
}
/// Find company IDs that match a company name query (case-insensitive partial match)
///
/// Returns a list of matching company IDs, or an empty list if:
/// - No matches are found
/// - The data has not been loaded yet (see [load] method)
///
/// **Important:** The [load] method must be called on app startup before
/// using this method. If called before data is loaded, this method will
/// return an empty list and log a warning (only once).
List<int> findCompanyIdsByName(String query) {
if (!_isLoaded) {
if (!_hasWarnedAboutNotLoaded) {
_hasWarnedAboutNotLoaded = true;
debugPrint(
'CompanyIdentifierService: findCompanyIdsByName called before load() completed. '
'Company identifier search will not be available until load() is called on app startup.',
);
}
return [];
}
final lowerQuery = query.toLowerCase();
return _companyNameToId.entries
.where((entry) => entry.key.contains(lowerQuery))
.map((entry) => entry.value)
.toList();
}
/// Parse a company identifier from a string (either by name or by ID)
///
/// This method first tries to find the company by name (case-insensitive).
/// If not found, it attempts to parse the string as a company ID:
/// - Supports hex format with "0x" prefix (e.g., "0x004C")
/// - Supports hex format without prefix if it contains hex characters (e.g., "4C")
/// - Falls back to decimal parsing if no hex characters are present (e.g., "76")
///
/// Returns the company ID if found/parsed successfully, null otherwise.
int? parseCompanyIdentifier(String value) {
final trimmed = value.trim();
// First, try to find by company name (case-insensitive)
final companyId = getCompanyIdFromName(trimmed);
if (companyId != null) {
return companyId;
}
// If not found by name, try parsing as company ID
if (trimmed.toLowerCase().startsWith('0x')) {
return 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]'))) {
return int.tryParse(trimmed, radix: 16);
} else {
return int.tryParse(trimmed);
}
}
}
}
+51 -21
View File
@@ -4,6 +4,7 @@ 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';
@@ -52,6 +53,9 @@ class _ScannerScreenState extends State<ScannerScreen> {
_loadScanFilters();
// Load company identifiers in the background
CompanyIdentifierService.instance.load();
// Save search filter when it changes
_searchFilterController.addListener(() {
_saveScanFilters();
@@ -149,8 +153,10 @@ class _ScannerScreenState extends State<ScannerScreen> {
}
}
void _applyFilterFromControllers() {
void _applyFilterFromControllers() async {
try {
// Ensure company identifier service is loaded
await CompanyIdentifierService.instance.load();
List<String> serviceUUids = [];
List<String> namePrefixes = [];
List<ManufacturerDataFilter> manufacturerDataFilters = [];
@@ -181,23 +187,15 @@ class _ScannerScreenState extends State<ScannerScreen> {
// 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) {
String trimmed = manufacturer.trim();
int? companyIdentifier;
if (trimmed.toLowerCase().startsWith('0x')) {
companyIdentifier = int.tryParse(trimmed.substring(2), radix: 16);
} else {
if (trimmed.contains(RegExp(r'[a-fA-F]'))) {
companyIdentifier = int.tryParse(trimmed, radix: 16);
} else {
companyIdentifier = int.tryParse(trimmed);
}
}
final companyIdentifier =
companyService.parseCompanyIdentifier(manufacturer);
if (companyIdentifier == null) {
// Skip invalid manufacturer data when loading from preferences
continue;
@@ -256,13 +254,48 @@ class _ScannerScreenState extends State<ScannerScreen> {
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();
return name.contains(filter) ||
// Check if filter matches device name, ID, or services
if (name.contains(filter) ||
deviceId.contains(filter) ||
services.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();
}
@@ -334,17 +367,15 @@ class _ScannerScreenState extends State<ScannerScreen> {
child: TextField(
controller: _searchFilterController,
decoration: InputDecoration(
hintText: 'Search by name, ID, or services...',
hintText: 'Search by name, ID, service, company name/ID',
prefixIcon: Icon(
Icons.search,
color: colorScheme.primary,
size: 20,
),
suffixIcon: _searchFilterController.text.isNotEmpty
? IconButton(
icon: Icon(
Icons.clear,
size: 20,
color:
colorScheme.onSurface.withValues(alpha: 0.6),
),
@@ -356,16 +387,15 @@ class _ScannerScreenState extends State<ScannerScreen> {
)
: null,
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(8),
borderRadius: BorderRadius.circular(12),
borderSide: BorderSide.none,
),
filled: true,
fillColor: colorScheme.surfaceContainerHighest,
contentPadding: const EdgeInsets.symmetric(
horizontal: 12,
vertical: 8,
horizontal: 16,
vertical: 16,
),
isDense: true,
),
onChanged: (_) => setState(() {}),
),
@@ -1,5 +1,6 @@
import 'package:flutter/material.dart';
import 'package:universal_ble/universal_ble.dart';
import 'package:universal_ble_example/data/company_identifier_service.dart';
class ScanFilterWidget extends StatefulWidget {
final void Function(ScanFilter? filter) onScanFilter;
@@ -22,11 +23,13 @@ class ScanFilterWidget extends StatefulWidget {
class _ScanFilterWidgetState extends State<ScanFilterWidget> {
String? error;
void applyFilter() {
void applyFilter() async {
setState(() {
error = null;
});
try {
// Ensure company identifier service is loaded
await CompanyIdentifierService.instance.load();
List<String> serviceUUids = [];
List<String> namePrefixes = [];
List<ManufacturerDataFilter> manufacturerDataFilters = [];
@@ -56,27 +59,18 @@ class _ScanFilterWidgetState extends State<ScanFilterWidget> {
// Parse Manufacturer Data - handle both comma and newline separated
String manufacturerDataText = widget.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) {
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);
}
}
final companyIdentifier = companyService.parseCompanyIdentifier(manufacturer);
if (companyIdentifier == null) {
throw Exception("Invalid Manufacturer Data $manufacturer");
throw Exception(
"Invalid Manufacturer Data or Company Name: $manufacturer");
}
manufacturerDataFilters.add(
ManufacturerDataFilter(companyIdentifier: companyIdentifier));
@@ -95,11 +89,15 @@ class _ScanFilterWidgetState extends State<ScanFilterWidget> {
withManufacturerData: manufacturerDataFilters,
),
);
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text("Filters Applied")),
);
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text("Filters Applied")),
);
}
}
if (mounted) {
Navigator.pop(context);
}
Navigator.pop(context);
} catch (e) {
setState(() {
error = e.toString();
@@ -228,8 +226,9 @@ class _ScanFilterWidgetState extends State<ScanFilterWidget> {
title: "Manufacturer Company IDs",
icon: Icons.business,
controller: widget.manufacturerDataController,
hintText: "e.g. 76,0x004C",
helperText: "Company identifiers in decimal or hex format",
hintText: "e.g. 76,0x004C,Apple, Inc.",
helperText:
"Company identifiers (decimal/hex) or company names",
),
const SizedBox(height: 24),
// Action Buttons
@@ -1,7 +1,9 @@
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:universal_ble/universal_ble.dart';
import 'package:universal_ble_example/data/company_identifier_service.dart';
import 'package:universal_ble_example/home/widgets/rssi_signal_indicator.dart';
import 'package:universal_ble_example/widgets/company_info_widget.dart';
class ScannedItemWidget extends StatelessWidget {
final BleDevice bleDevice;
@@ -164,6 +166,9 @@ class ScannedItemWidget extends StatelessWidget {
// Manufacturer data (only in collapsed mode)
if (!isExpanded) ...[
...rawManufacturerData.take(2).map((data) {
final companyName = CompanyIdentifierService
.instance
.getCompanyName(data.companyId);
return Container(
padding: const EdgeInsets.symmetric(
horizontal: 8,
@@ -173,13 +178,37 @@ class ScannedItemWidget extends StatelessWidget {
color: colorScheme.secondaryContainer,
borderRadius: BorderRadius.circular(8),
),
child: Text(
data.companyIdRadix16,
style: TextStyle(
fontSize: 10,
color: colorScheme.onSecondaryContainer,
fontWeight: FontWeight.w500,
),
child: Column(
crossAxisAlignment:
CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
Text(
data.companyIdRadix16,
style: TextStyle(
fontSize: 10,
color:
colorScheme.onSecondaryContainer,
fontWeight: FontWeight.w500,
fontFamily: 'monospace',
),
),
if (companyName != null) ...[
const SizedBox(height: 2),
Text(
companyName,
style: TextStyle(
fontSize: 9,
color: colorScheme
.onSecondaryContainer
.withValues(alpha: 0.8),
fontWeight: FontWeight.w400,
),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
],
],
),
);
}),
@@ -285,6 +314,22 @@ class ScannedItemWidget extends StatelessWidget {
),
],
),
CompanyInfoWidget(
companyId: data.companyId,
colorScheme: colorScheme,
labelStyle: TextStyle(
fontSize: 11,
fontWeight: FontWeight.w600,
color:
colorScheme.onSecondaryContainer,
),
nameStyle: TextStyle(
fontSize: 11,
fontWeight: FontWeight.w500,
color:
colorScheme.onSecondaryContainer,
),
),
if (data.payloadRadix16.isNotEmpty) ...[
const SizedBox(height: 4),
Row(
@@ -9,6 +9,7 @@ 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/peripheral_details/widgets/services_side_widget.dart';
import 'package:universal_ble_example/widgets/company_info_widget.dart';
import 'package:universal_ble_example/widgets/responsive_view.dart';
class PeripheralDetailPage extends StatefulWidget {
@@ -1032,6 +1033,20 @@ class _PeripheralDetailPageState extends State<PeripheralDetailPage> {
),
],
),
CompanyInfoWidget(
companyId: data.companyId,
colorScheme: colorScheme,
labelStyle: TextStyle(
fontSize: 12,
fontWeight: FontWeight.w600,
color: colorScheme.onSecondaryContainer,
),
nameStyle: TextStyle(
fontSize: 12,
fontWeight: FontWeight.w500,
color: colorScheme.onSecondaryContainer,
),
),
if (data.payloadRadix16.isNotEmpty) ...[
const SizedBox(height: 4),
Row(
@@ -0,0 +1,75 @@
import 'package:flutter/material.dart';
import 'package:universal_ble_example/data/company_identifier_service.dart';
/// A reusable widget that displays company information for a given company ID.
///
/// This widget fetches the company name from the CompanyIdentifierService
/// and displays it in a consistent format. If no company name is found,
/// the widget returns an empty SizedBox.
class CompanyInfoWidget extends StatelessWidget {
/// The company ID to look up
final int companyId;
/// Optional text style for the "Company:" label
final TextStyle? labelStyle;
/// Optional text style for the company name
final TextStyle? nameStyle;
/// Optional padding around the widget
final EdgeInsets? padding;
/// Optional color scheme. If not provided, will be obtained from Theme
final ColorScheme? colorScheme;
const CompanyInfoWidget({
super.key,
required this.companyId,
this.labelStyle,
this.nameStyle,
this.padding,
this.colorScheme,
});
@override
Widget build(BuildContext context) {
final companyName = CompanyIdentifierService.instance.getCompanyName(companyId);
if (companyName == null) {
return const SizedBox.shrink();
}
final effectiveColorScheme = colorScheme ?? Theme.of(context).colorScheme;
final effectiveLabelStyle = labelStyle ??
TextStyle(
fontSize: 11,
fontWeight: FontWeight.w600,
color: effectiveColorScheme.onSecondaryContainer,
);
final effectiveNameStyle = nameStyle ??
TextStyle(
fontSize: 11,
fontWeight: FontWeight.w500,
color: effectiveColorScheme.onSecondaryContainer,
);
final effectivePadding = padding ?? const EdgeInsets.only(top: 4);
return Padding(
padding: effectivePadding,
child: Row(
children: [
Text(
'Company: ',
style: effectiveLabelStyle,
),
Expanded(
child: SelectableText(
companyName,
style: effectiveNameStyle,
),
),
],
),
);
}
}
+1 -1
View File
@@ -754,7 +754,7 @@ packages:
source: hosted
version: "6.6.1"
yaml:
dependency: transitive
dependency: "direct main"
description:
name: yaml
sha256: b9da305ac7c39faa3f030eccd175340f968459dae4af175130b3fc47e40d76ce
+2
View File
@@ -16,6 +16,7 @@ dependencies:
package_info_plus: ^9.0.0
url_launcher: ^6.3.2
path_provider: ^2.1.1
yaml: ^3.1.3
universal_ble:
path: ../
device_preview: ^1.3.1
@@ -37,3 +38,4 @@ flutter:
uses-material-design: true
assets:
- assets/icon.png
- assets/company_identifiers.yaml