Switch to simple example app

This commit is contained in:
Foti Dim
2026-02-28 09:29:57 +02:00
parent aeca9fa6a9
commit 57ac324705
104 changed files with 1191 additions and 20034 deletions
+5 -1
View File
@@ -847,6 +847,10 @@ Future<void> resetBleState() async {
</details>
## Example app
This repo includes an [example app](example/) you can run to try the API. For a full-blown app, check [Universal-BLE](https://github.com/Navideck/Universal-BLE).
## Low level API
For more granular control, you can use the [Low-Level API](README.low_level.md). This API is "Device ID"-based, offering greater flexibility by enabling direct calls without the need for object instances.
@@ -855,7 +859,7 @@ For more granular control, you can use the [Low-Level API](README.low_level.md).
Here are some of the apps leveraging the power of `universal_ble`:
- [**Universal BLE**](https://apps.apple.com/app/id6756538573) ([App Store](https://apps.apple.com/app/id6756538573) | [Play Store](https://play.google.com/store/apps/details?id=com.navideck.universalble) | [Web](https://navideck.github.io/universal_ble)) - A comprehensive developer tool for exploring and testing Bluetooth Low Energy (BLE) devices. It enables scanning for nearby BLE devices, connecting to peripherals, discovering and exploring services, characteristics, and descriptors. Supports reading and writing characteristic values, enabling notifications and indications, viewing device information and signal strength, and provides detailed logging of BLE operations. Perfect for developers, engineers, and hobbyist tinkerers working with BLE-enabled devices across iOS, Android, macOS, Windows, Linux & Web.
- [**Universal BLE**](https://github.com/Navideck/Universal-BLE) - A comprehensive developer tool for exploring and testing Bluetooth Low Energy (BLE) devices. It enables scanning for nearby BLE devices, connecting to peripherals, discovering and exploring services, characteristics, and descriptors. Supports reading and writing characteristic values, enabling notifications and indications, viewing device information and signal strength, and provides detailed logging of BLE operations. Perfect for developers, engineers, and hobbyist tinkerers working with BLE-enabled devices across iOS, Android, macOS, Windows, Linux & Web.
- [**BT Cam**](https://btcam.app) - A Bluetooth remote app for DSLR and mirrorless cameras. Compatible with Canon, Nikon, Sony, Fujifilm, GoPro, Olympus, Panasonic, Pentax, and Blackmagic. Built using Universal BLE to connect and control cameras across iOS, Android, macOS, Windows, Linux & Web.
- [**TukToro**](https://tuktoro.com/en/pages/download-math-learning-app) - Interactive math learning app for kids. Available on iPad and Android tablets, featuring hand-drawn levels, didactic learning games, and ad-free child-safe environment.
- [**BikeControl**](https://github.com/jonasbark/swiftcontrol) - Control your favorite trainer app using Zwift Click, Zwift Ride, Zwift Play, Shimano Di2, or other similar devices. Enables virtual gear shifting, steering, workout intensity adjustment, and more across iOS, Android, macOS, Windows, and Linux.
-33
View File
@@ -1,33 +0,0 @@
## 1.1.0
* **Services & Characteristics:**
* Add property filtering for characteristics with visual filter chips
* Add navigation buttons to navigate between characteristics (previous/next)
* Improve service sorting (favorites first, system services last)
* Enhance services list UI with better filtering and navigation
* Improve format for discovered services to be more detailed and human-readable
* Move "Copy Services" button to Services panel header
* **Company & Manufacturer Data:**
* Display company name based on company identifier from manufacturer data
* Show and filter by company name in device list
* Enhanced search functionality - now supports searching by company name
* **Scanning & Device Discovery:**
* Improved scan button visibility - converted to prominent filled button with text label
* Enhanced "no devices found" state with explicit "Start Scan" call-to-action button
* Display RSSI values in device details
* Show last advertisement (name, RSSI, timestamp) on scan screen and list of received ads on detail screen; both flash on new advertisement. Scanning continues when opening a device.
* **UI & Navigation:**
* 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)
* Improved overall UI layout and navigation flow
* **Functionality:**
* Add support for `autoConnect` parameter
* Persist filters across app sessions
* Fix clear log button functionality
## 1.0.0
* Initial release
+2 -67
View File
@@ -1,68 +1,3 @@
Universal BLE is an open source powerful developer tool for exploring and testing Bluetooth Low Energy (BLE) devices.
# universal_ble_example
Universal BLE is a comprehensive cross-platform developer tool that empowers developers, engineers, and hobbyists to explore, test, and debug Bluetooth Low Energy (BLE) devices with ease. Built with Flutter and supporting multiple platforms, this powerful tool provides everything you need to work with BLE-enabled devices.
Built on the open source universal_ble Flutter plugin - one of the most comprehensive cross-platform BLE solutions available. Package: https://pub.dev/packages/universal_ble
Key Features
Device Discovery
- Scan for nearby BLE devices in real-time
- Filter devices by services, manufacturer data, or name prefixes
- View device information, signal strength, and connection status
- Access already connected system devices
Connection Management
- Connect and disconnect from BLE peripherals
- Monitor connection state changes in real-time
- Support for multiple simultaneous connections
- Automatic reconnection handling
Service & Characteristic Exploration
- Discover all services and characteristics on connected devices
- Browse GATT services with detailed UUID information
- View characteristic properties and descriptors
- Intuitive interface for navigating device capabilities
Data Operations
- Read characteristic values in real-time
- Write data to characteristics (with or without response)
- Subscribe to notifications and indications
- Monitor value changes with live updates
Pairing & Security
- Initiate pairing with BLE devices
- Check pairing status and monitor pairing state changes
- Support for encrypted characteristics
- Unpair devices when needed
Advanced Features
- Request MTU (Maximum Transmission Unit) for optimized data transfer
- Command queue system for reliable operation
- Configurable timeouts for all operations
- Comprehensive error handling with detailed error codes
- UUID format agnostic (supports all UUID formats)
- Detailed logging for debugging BLE operations
Cross-Platform Support
- Native support with consistent API across all platforms
- Platform-specific optimizations where needed
Perfect For
- Developers building BLE-enabled applications
- Engineers testing and debugging BLE devices
- Hobbyists exploring IoT and smart device capabilities
- Students learning about Bluetooth Low Energy technology
- QA Teams validating BLE device functionality
Why Universal BLE?
Universal BLE is built on the open source universal_ble Flutter plugin, one of the most comprehensive cross-platform BLE solutions available. With support for all major platforms and a unified API, you can develop and test BLE applications with confidence.
Whether you're developing a new BLE product, debugging an existing device, or simply exploring the world of Bluetooth Low Energy, Universal BLE provides the tools you need to succeed.
Open Source & Free
This app is built on open source technology and is free to use. The underlying plugin is available under the BSD 3-Clause License, making it perfect for both commercial and personal projects.
Universal BLE - Your complete solution for Bluetooth Low Energy development and testing.
Demonstrates how to use the universal_ble plugin.
+2 -2
View File
@@ -23,7 +23,7 @@ if (flutterVersionName == null) {
}
android {
namespace = "com.navideck.universalble"
namespace "com.navideck.universal_ble_example"
compileSdkVersion 36
ndkVersion flutter.ndkVersion
@@ -41,7 +41,7 @@ android {
}
defaultConfig {
applicationId = "com.navideck.universalble"
applicationId "com.navideck.universal_ble_example"
// You can update the following values to match your application needs.
// For more information, see: https://docs.flutter.dev/deployment/android#reviewing-the-gradle-build-configuration.
minSdkVersion flutter.minSdkVersion
@@ -6,7 +6,7 @@
<uses-permission android:name="android.permission.ACCESS_COARSE_LOCATION" android:maxSdkVersion="30" />
<application
android:label="Universal BLE"
android:label="universal_ble_example"
android:name="${applicationName}"
android:icon="@mipmap/ic_launcher">
<activity
@@ -1,4 +1,4 @@
package com.navideck.universalble
package com.navideck.universal_ble_example
import io.flutter.embedding.android.FlutterActivity
Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.6 KiB

After

Width:  |  Height:  |  Size: 544 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.4 KiB

After

Width:  |  Height:  |  Size: 442 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 10 KiB

After

Width:  |  Height:  |  Size: 721 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 20 KiB

After

Width:  |  Height:  |  Size: 1.0 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 30 KiB

After

Width:  |  Height:  |  Size: 1.4 KiB

File diff suppressed because it is too large Load Diff
Binary file not shown.

Before

Width:  |  Height:  |  Size: 718 KiB

-4
View File
@@ -1,4 +0,0 @@
description: This file stores settings for Dart & Flutter DevTools.
documentation: https://docs.flutter.dev/tools/devtools/extensions#configure-extension-enablement-states
extensions:
- shared_preferences: true
-27
View File
@@ -1,27 +0,0 @@
# flutter pub run flutter_launcher_icons
flutter_launcher_icons:
image_path: "assets/icon.png"
android: "ic_launcher"
image_path_android: "assets/icon.png"
min_sdk_android: 21 # android min sdk min:16, default 21
# adaptive_icon_background: "#2C2B2B"
# adaptive_icon_foreground: "assets/ic_launcher_monochrome.png"
ios: true
remove_alpha_ios: true
web:
generate: true
image_path: "assets/icon.png"
# background_color: "#2C2B2B"
# theme_color: "#2C2B2B"
windows:
generate: true
image_path: "assets/icon.png"
icon_size: 48 # min:48, max:256, default: 48
macos:
generate: true
image_path: "assets/icon.png"
+1 -1
View File
@@ -1,5 +1,5 @@
# Uncomment this line to define a global platform for your project
platform :ios, '13.1'
# platform :ios, '13.0'
# CocoaPods analytics sends network stats synchronously affecting flutter build latency.
ENV['COCOAPODS_DISABLE_STATS'] = 'true'
+3 -29
View File
@@ -2,54 +2,28 @@ PODS:
- Flutter (1.0.0)
- integration_test (0.0.1):
- Flutter
- package_info_plus (0.4.5):
- Flutter
- path_provider_foundation (0.0.1):
- Flutter
- FlutterMacOS
- shared_preferences_foundation (0.0.1):
- Flutter
- FlutterMacOS
- universal_ble (0.0.1):
- Flutter
- FlutterMacOS
- url_launcher_ios (0.0.1):
- Flutter
DEPENDENCIES:
- Flutter (from `Flutter`)
- integration_test (from `.symlinks/plugins/integration_test/ios`)
- package_info_plus (from `.symlinks/plugins/package_info_plus/ios`)
- path_provider_foundation (from `.symlinks/plugins/path_provider_foundation/darwin`)
- shared_preferences_foundation (from `.symlinks/plugins/shared_preferences_foundation/darwin`)
- universal_ble (from `.symlinks/plugins/universal_ble/darwin`)
- url_launcher_ios (from `.symlinks/plugins/url_launcher_ios/ios`)
EXTERNAL SOURCES:
Flutter:
:path: Flutter
integration_test:
:path: ".symlinks/plugins/integration_test/ios"
package_info_plus:
:path: ".symlinks/plugins/package_info_plus/ios"
path_provider_foundation:
:path: ".symlinks/plugins/path_provider_foundation/darwin"
shared_preferences_foundation:
:path: ".symlinks/plugins/shared_preferences_foundation/darwin"
universal_ble:
:path: ".symlinks/plugins/universal_ble/darwin"
url_launcher_ios:
:path: ".symlinks/plugins/url_launcher_ios/ios"
SPEC CHECKSUMS:
Flutter: cabc95a1d2626b1b06e7179b784ebcf0c0cde467
integration_test: 4a889634ef21a45d28d50d622cf412dc6d9f586e
package_info_plus: af8e2ca6888548050f16fa2f1938db7b5a5df499
path_provider_foundation: bb55f6dbba17d0dccd6737fe6f7f34fbd0376880
shared_preferences_foundation: 7036424c3d8ec98dfe75ff1667cb0cd531ec82bb
universal_ble: 45519b2aeafe62761e2c6309f8927edb5288b914
url_launcher_ios: 7a95fa5b60cc718a708b8f2966718e93db0cef1b
integration_test: 252f60fa39af5e17c3aa9899d35d908a0721b573
universal_ble: 65e1257dffc557cc7991a93d253beeddc7c1dc92
PODFILE CHECKSUM: 65200e6210154a397af888bf7e7c86dbadc6d523
PODFILE CHECKSUM: 4f1c12611da7338d21589c0b2ecd6bd20b109694
COCOAPODS: 1.16.2
+6 -6
View File
@@ -475,7 +475,7 @@
"$(inherited)",
"@executable_path/Frameworks",
);
PRODUCT_BUNDLE_IDENTIFIER = com.navideck.universalble;
PRODUCT_BUNDLE_IDENTIFIER = com.navideck.universalBleExample;
PRODUCT_NAME = "$(TARGET_NAME)";
SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h";
SWIFT_VERSION = 5.0;
@@ -492,7 +492,7 @@
CURRENT_PROJECT_VERSION = 1;
GENERATE_INFOPLIST_FILE = YES;
MARKETING_VERSION = 1.0;
PRODUCT_BUNDLE_IDENTIFIER = com.navideck.universalble.RunnerTests;
PRODUCT_BUNDLE_IDENTIFIER = com.navideck.universalBleExample.RunnerTests;
PRODUCT_NAME = "$(TARGET_NAME)";
SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG;
SWIFT_OPTIMIZATION_LEVEL = "-Onone";
@@ -510,7 +510,7 @@
CURRENT_PROJECT_VERSION = 1;
GENERATE_INFOPLIST_FILE = YES;
MARKETING_VERSION = 1.0;
PRODUCT_BUNDLE_IDENTIFIER = com.navideck.universalble.RunnerTests;
PRODUCT_BUNDLE_IDENTIFIER = com.navideck.universalBleExample.RunnerTests;
PRODUCT_NAME = "$(TARGET_NAME)";
SWIFT_VERSION = 5.0;
TEST_HOST = "$(BUILT_PRODUCTS_DIR)/Runner.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/Runner";
@@ -526,7 +526,7 @@
CURRENT_PROJECT_VERSION = 1;
GENERATE_INFOPLIST_FILE = YES;
MARKETING_VERSION = 1.0;
PRODUCT_BUNDLE_IDENTIFIER = com.navideck.universalble.RunnerTests;
PRODUCT_BUNDLE_IDENTIFIER = com.navideck.universalBleExample.RunnerTests;
PRODUCT_NAME = "$(TARGET_NAME)";
SWIFT_VERSION = 5.0;
TEST_HOST = "$(BUILT_PRODUCTS_DIR)/Runner.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/Runner";
@@ -654,7 +654,7 @@
"$(inherited)",
"@executable_path/Frameworks",
);
PRODUCT_BUNDLE_IDENTIFIER = com.navideck.universalble;
PRODUCT_BUNDLE_IDENTIFIER = com.navideck.universalBleExample;
PRODUCT_NAME = "$(TARGET_NAME)";
SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h";
SWIFT_OPTIMIZATION_LEVEL = "-Onone";
@@ -677,7 +677,7 @@
"$(inherited)",
"@executable_path/Frameworks",
);
PRODUCT_BUNDLE_IDENTIFIER = com.navideck.universalble;
PRODUCT_BUNDLE_IDENTIFIER = com.navideck.universalBleExample;
PRODUCT_NAME = "$(TARGET_NAME)";
SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h";
SWIFT_VERSION = 5.0;
@@ -1 +1,122 @@
{"images":[{"size":"20x20","idiom":"iphone","filename":"Icon-App-20x20@2x.png","scale":"2x"},{"size":"20x20","idiom":"iphone","filename":"Icon-App-20x20@3x.png","scale":"3x"},{"size":"29x29","idiom":"iphone","filename":"Icon-App-29x29@1x.png","scale":"1x"},{"size":"29x29","idiom":"iphone","filename":"Icon-App-29x29@2x.png","scale":"2x"},{"size":"29x29","idiom":"iphone","filename":"Icon-App-29x29@3x.png","scale":"3x"},{"size":"40x40","idiom":"iphone","filename":"Icon-App-40x40@2x.png","scale":"2x"},{"size":"40x40","idiom":"iphone","filename":"Icon-App-40x40@3x.png","scale":"3x"},{"size":"57x57","idiom":"iphone","filename":"Icon-App-57x57@1x.png","scale":"1x"},{"size":"57x57","idiom":"iphone","filename":"Icon-App-57x57@2x.png","scale":"2x"},{"size":"60x60","idiom":"iphone","filename":"Icon-App-60x60@2x.png","scale":"2x"},{"size":"60x60","idiom":"iphone","filename":"Icon-App-60x60@3x.png","scale":"3x"},{"size":"20x20","idiom":"ipad","filename":"Icon-App-20x20@1x.png","scale":"1x"},{"size":"20x20","idiom":"ipad","filename":"Icon-App-20x20@2x.png","scale":"2x"},{"size":"29x29","idiom":"ipad","filename":"Icon-App-29x29@1x.png","scale":"1x"},{"size":"29x29","idiom":"ipad","filename":"Icon-App-29x29@2x.png","scale":"2x"},{"size":"40x40","idiom":"ipad","filename":"Icon-App-40x40@1x.png","scale":"1x"},{"size":"40x40","idiom":"ipad","filename":"Icon-App-40x40@2x.png","scale":"2x"},{"size":"50x50","idiom":"ipad","filename":"Icon-App-50x50@1x.png","scale":"1x"},{"size":"50x50","idiom":"ipad","filename":"Icon-App-50x50@2x.png","scale":"2x"},{"size":"72x72","idiom":"ipad","filename":"Icon-App-72x72@1x.png","scale":"1x"},{"size":"72x72","idiom":"ipad","filename":"Icon-App-72x72@2x.png","scale":"2x"},{"size":"76x76","idiom":"ipad","filename":"Icon-App-76x76@1x.png","scale":"1x"},{"size":"76x76","idiom":"ipad","filename":"Icon-App-76x76@2x.png","scale":"2x"},{"size":"83.5x83.5","idiom":"ipad","filename":"Icon-App-83.5x83.5@2x.png","scale":"2x"},{"size":"1024x1024","idiom":"ios-marketing","filename":"Icon-App-1024x1024@1x.png","scale":"1x"}],"info":{"version":1,"author":"xcode"}}
{
"images" : [
{
"size" : "20x20",
"idiom" : "iphone",
"filename" : "Icon-App-20x20@2x.png",
"scale" : "2x"
},
{
"size" : "20x20",
"idiom" : "iphone",
"filename" : "Icon-App-20x20@3x.png",
"scale" : "3x"
},
{
"size" : "29x29",
"idiom" : "iphone",
"filename" : "Icon-App-29x29@1x.png",
"scale" : "1x"
},
{
"size" : "29x29",
"idiom" : "iphone",
"filename" : "Icon-App-29x29@2x.png",
"scale" : "2x"
},
{
"size" : "29x29",
"idiom" : "iphone",
"filename" : "Icon-App-29x29@3x.png",
"scale" : "3x"
},
{
"size" : "40x40",
"idiom" : "iphone",
"filename" : "Icon-App-40x40@2x.png",
"scale" : "2x"
},
{
"size" : "40x40",
"idiom" : "iphone",
"filename" : "Icon-App-40x40@3x.png",
"scale" : "3x"
},
{
"size" : "60x60",
"idiom" : "iphone",
"filename" : "Icon-App-60x60@2x.png",
"scale" : "2x"
},
{
"size" : "60x60",
"idiom" : "iphone",
"filename" : "Icon-App-60x60@3x.png",
"scale" : "3x"
},
{
"size" : "20x20",
"idiom" : "ipad",
"filename" : "Icon-App-20x20@1x.png",
"scale" : "1x"
},
{
"size" : "20x20",
"idiom" : "ipad",
"filename" : "Icon-App-20x20@2x.png",
"scale" : "2x"
},
{
"size" : "29x29",
"idiom" : "ipad",
"filename" : "Icon-App-29x29@1x.png",
"scale" : "1x"
},
{
"size" : "29x29",
"idiom" : "ipad",
"filename" : "Icon-App-29x29@2x.png",
"scale" : "2x"
},
{
"size" : "40x40",
"idiom" : "ipad",
"filename" : "Icon-App-40x40@1x.png",
"scale" : "1x"
},
{
"size" : "40x40",
"idiom" : "ipad",
"filename" : "Icon-App-40x40@2x.png",
"scale" : "2x"
},
{
"size" : "76x76",
"idiom" : "ipad",
"filename" : "Icon-App-76x76@1x.png",
"scale" : "1x"
},
{
"size" : "76x76",
"idiom" : "ipad",
"filename" : "Icon-App-76x76@2x.png",
"scale" : "2x"
},
{
"size" : "83.5x83.5",
"idiom" : "ipad",
"filename" : "Icon-App-83.5x83.5@2x.png",
"scale" : "2x"
},
{
"size" : "1024x1024",
"idiom" : "ios-marketing",
"filename" : "Icon-App-1024x1024@1x.png",
"scale" : "1x"
}
],
"info" : {
"version" : 1,
"author" : "xcode"
}
}
Binary file not shown.

Before

Width:  |  Height:  |  Size: 663 KiB

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 820 B

After

Width:  |  Height:  |  Size: 295 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.5 KiB

After

Width:  |  Height:  |  Size: 406 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.8 KiB

After

Width:  |  Height:  |  Size: 450 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.5 KiB

After

Width:  |  Height:  |  Size: 282 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.6 KiB

After

Width:  |  Height:  |  Size: 462 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 8.7 KiB

After

Width:  |  Height:  |  Size: 704 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.5 KiB

After

Width:  |  Height:  |  Size: 406 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 7.7 KiB

After

Width:  |  Height:  |  Size: 586 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 15 KiB

After

Width:  |  Height:  |  Size: 862 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 15 KiB

After

Width:  |  Height:  |  Size: 862 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 28 KiB

After

Width:  |  Height:  |  Size: 1.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 20 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 7.1 KiB

After

Width:  |  Height:  |  Size: 762 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 21 KiB

After

Width:  |  Height:  |  Size: 1.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 24 KiB

After

Width:  |  Height:  |  Size: 1.4 KiB

+1 -1
View File
@@ -13,7 +13,7 @@
<key>CFBundleInfoDictionaryVersion</key>
<string>6.0</string>
<key>CFBundleName</key>
<string>Universal BLE</string>
<string>universal_ble_example</string>
<key>CFBundlePackageType</key>
<string>APPL</string>
<key>CFBundleShortVersionString</key>
@@ -1,278 +0,0 @@
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);
}
}
}
}
+19 -32
View File
@@ -5,7 +5,6 @@ import 'package:universal_ble/universal_ble.dart';
/// Mock implementation of [UniversalBlePlatform] for testing
class MockUniversalBle extends UniversalBlePlatform {
final Map<String, BleConnectionState> _connectionStateMap = {};
final _mockBleDevice = BleDevice(
name: 'MockDevice',
deviceId: 'MockDeviceId',
@@ -16,20 +15,12 @@ class MockUniversalBle extends UniversalBlePlatform {
Uint8List _serviceValue = utf8.encode('Result');
bool _isScanning = false;
final BleService _mockService = BleService('180a', [
BleCharacteristic.withMetaData(
deviceId: 'MockDeviceId',
serviceId: '180a',
uuid: '220a',
properties: [
CharacteristicProperty.read,
CharacteristicProperty.write,
CharacteristicProperty.notify,
],
descriptors: [
BleDescriptor('220b'),
],
),
final BleService _mockService = BleService('180', [
BleCharacteristic('180A', [
CharacteristicProperty.read,
CharacteristicProperty.write,
CharacteristicProperty.notify,
], []),
]);
@override
@@ -53,15 +44,13 @@ class MockUniversalBle extends UniversalBlePlatform {
@override
Future<void> connect(String deviceId,
{bool autoConnect = false, Duration? connectionTimeout}) async {
{Duration? connectionTimeout, bool autoConnect = false}) async {
updateConnection(deviceId, true);
_connectionStateMap[deviceId] = BleConnectionState.connected;
}
@override
Future<void> disconnect(String deviceId) async {
updateConnection(deviceId, false);
_connectionStateMap[deviceId] = BleConnectionState.disconnected;
}
@override
@@ -83,7 +72,7 @@ class MockUniversalBle extends UniversalBlePlatform {
@override
Future<List<BleDevice>> getSystemDevices(List<String>? withServices) async {
return [_mockBleDevice];
return [];
}
@override
@@ -114,12 +103,6 @@ class MockUniversalBle extends UniversalBlePlatform {
return 512;
}
@override
Future<int> readRssi(String deviceId) async {
await Future.delayed(const Duration(milliseconds: 500));
return -50; // Mock RSSI value in dBm
}
@override
Future<void> setNotifiable(String deviceId, String service,
String characteristic, BleInputProperty bleInputProperty) async {}
@@ -142,18 +125,22 @@ class MockUniversalBle extends UniversalBlePlatform {
}
@override
Future<BleConnectionState> getConnectionState(String deviceId) async {
return _connectionStateMap[deviceId] ?? BleConnectionState.disconnected;
Future<BleConnectionState> getConnectionState(String deviceId) {
throw UnimplementedError();
}
@override
Future<bool> disableBluetooth() async {
return true;
Future<bool> disableBluetooth() {
throw UnimplementedError();
}
@override
Future<void> requestPermissions(
{bool withAndroidFineLocation = false}) async {
return;
Future<void> requestPermissions({bool withAndroidFineLocation = false}) {
throw UnimplementedError();
}
@override
Future<int> readRssi(String deviceId) {
throw UnimplementedError();
}
}
-5
View File
@@ -1,5 +0,0 @@
import 'package:shared_preferences/shared_preferences.dart';
/// Shared [SharedPreferencesAsync] instance for the example app.
/// Use this instead of the legacy [SharedPreferences.getInstance()] API.
final prefsAsync = SharedPreferencesAsync();
-63
View File
@@ -1,63 +0,0 @@
import 'dart:convert';
/// A saved scan filter preset (name + the three filter field values).
class SavedScanFilter {
const SavedScanFilter({
required this.name,
required this.services,
required this.namePrefix,
required this.manufacturerData,
});
final String name;
final String services;
final String namePrefix;
final String manufacturerData;
Map<String, dynamic> toJson() => {
'name': name,
'services': services,
'namePrefix': namePrefix,
'manufacturerData': manufacturerData,
};
factory SavedScanFilter.fromJson(Map<String, dynamic> json) {
return SavedScanFilter(
name: json['name'] as String? ?? '',
services: json['services'] as String? ?? '',
namePrefix: json['namePrefix'] as String? ?? '',
manufacturerData: json['manufacturerData'] as String? ?? '',
);
}
static List<SavedScanFilter> fromJsonList(String? jsonString) {
if (jsonString == null || jsonString.isEmpty) return [];
try {
final list = jsonDecode(jsonString) as List<dynamic>?;
if (list == null) return [];
return list
.map((e) => SavedScanFilter.fromJson(e as Map<String, dynamic>))
.toList();
} catch (_) {
return [];
}
}
static String toJsonList(List<SavedScanFilter> filters) {
return jsonEncode(filters.map((e) => e.toJson()).toList());
}
SavedScanFilter copyWith({
String? name,
String? services,
String? namePrefix,
String? manufacturerData,
}) {
return SavedScanFilter(
name: name ?? this.name,
services: services ?? this.services,
namePrefix: namePrefix ?? this.namePrefix,
manufacturerData: manufacturerData ?? this.manufacturerData,
);
}
}
-136
View File
@@ -1,136 +0,0 @@
import 'package:flutter/foundation.dart';
import 'package:universal_ble/universal_ble.dart';
import 'package:universal_ble_example/data/company_identifier_service.dart';
import 'package:universal_ble_example/data/prefs_async.dart';
/// Shared controller for BLE scan state so the scan button can be shown
/// on both the scanner screen and the peripheral detail screen.
class ScanController extends ChangeNotifier {
bool _isScanning = false;
bool get isScanning => _isScanning;
/// Syncs [isScanning] with the actual native state (e.g. after app restart).
Future<void> syncState() async {
final value = await UniversalBle.isScanning();
if (_isScanning != value) {
_isScanning = value;
notifyListeners();
}
}
Future<void> startScan() async {
if (_isScanning) return;
try {
final filter = await _loadFilterFromPrefs();
final platformConfig = await _loadPlatformConfigFromPrefs();
await UniversalBle.startScan(
scanFilter: filter,
platformConfig: platformConfig,
);
_isScanning = true;
notifyListeners();
} catch (e) {
_isScanning = false;
notifyListeners();
rethrow;
}
}
Future<void> stopScan() async {
if (!_isScanning) return;
try {
await UniversalBle.stopScan();
_isScanning = false;
notifyListeners();
} catch (e) {
rethrow;
}
}
Future<ScanFilter?> _loadFilterFromPrefs() async {
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')) ?? '';
if (services.isEmpty &&
namePrefix.isEmpty &&
manufacturerData.isEmpty) {
return null;
}
await CompanyIdentifierService.instance.load();
final serviceUUids = <String>[];
if (services.isNotEmpty) {
for (final s in services.split(',').map((e) => e.trim())) {
if (s.isEmpty) continue;
try {
serviceUUids.add(BleUuidParser.string(s));
} on FormatException catch (_) {
continue;
}
}
}
final namePrefixes = namePrefix.isEmpty
? <String>[]
: namePrefix.split(',').map((e) => e.trim()).toList();
final manufacturerDataFilters = <ManufacturerDataFilter>[];
if (manufacturerData.isNotEmpty) {
final companyService = CompanyIdentifierService.instance;
for (final s in manufacturerData
.split(',')
.map((e) => e.trim())
.where((s) => s.isNotEmpty)) {
final id = companyService.parseCompanyIdentifier(s);
if (id != null) {
manufacturerDataFilters.add(ManufacturerDataFilter(
companyIdentifier: id,
));
}
}
}
if (serviceUUids.isEmpty &&
namePrefixes.isEmpty &&
manufacturerDataFilters.isEmpty) {
return null;
}
return ScanFilter(
withServices: serviceUUids,
withNamePrefix: namePrefixes,
withManufacturerData: manufacturerDataFilters,
);
}
Future<PlatformConfig> _loadPlatformConfigFromPrefs() async {
WebOptions? web;
if (kIsWeb) {
final webServices =
(await prefsAsync.getString('scan_filter_web_services')) ?? '';
if (webServices.isNotEmpty) {
final list = webServices
.split(',')
.where((s) => s.trim().isNotEmpty)
.map((s) {
try {
return BleUuidParser.string(s.trim());
} catch (_) {
return s.trim();
}
}).toList();
web = WebOptions(optionalServices: list);
}
}
return PlatformConfig(
android: AndroidOptions(scanMode: AndroidScanMode.lowLatency),
web: web,
);
}
}
-18
View File
@@ -1,18 +0,0 @@
import 'package:universal_ble_example/data/prefs_async.dart';
class StorageService {
StorageService._();
static StorageService? _instance;
static StorageService get instance => _instance ??= StorageService._();
Future<void> init() async {
// SharedPreferencesAsync is used via prefsAsync; no async init needed.
}
Future<void> setFavoriteServices(List<String> services) async {
await prefsAsync.setStringList('favorite_services', services);
}
Future<List<String>> getFavoriteServices() async =>
(await prefsAsync.getStringList('favorite_services')) ?? [];
}
-104
View File
@@ -1,104 +0,0 @@
import 'package:universal_ble/universal_ble.dart';
bool isSystemService(String uuid) {
final normalized = uuid.toUpperCase().replaceAll('-', '');
return normalized == '00001800' ||
normalized == '00001801' ||
normalized == '0000180A' ||
normalized.startsWith('000018');
}
/// Sorts BLE services with the following priority:
/// 1. Favorite services first
/// 2. System services last
/// 3. Other services in between
List<BleService> sortBleServices(
List<BleService> services, {
Set<String>? favoriteServices,
}) {
final sortedServices = List<BleService>.from(services);
sortedServices.sort((a, b) {
final aIsFavorite = favoriteServices?.contains(a.uuid) ?? false;
final bIsFavorite = favoriteServices?.contains(b.uuid) ?? false;
if (aIsFavorite != bIsFavorite) {
return aIsFavorite ? -1 : 1;
}
final aIsSystem = isSystemService(a.uuid);
final bIsSystem = isSystemService(b.uuid);
if (aIsSystem != bIsSystem) {
return aIsSystem ? 1 : -1;
}
return 0;
});
return sortedServices;
}
/// Returns a list of all filtered characteristics with their parent services.
/// Services are sorted (favorites first, system services last).
/// Characteristics are filtered by property filters if provided.
List<({BleService service, BleCharacteristic characteristic})>
getFilteredBleCharacteristics(
List<BleService> services, {
Set<String>? favoriteServices,
Set<CharacteristicProperty>? propertyFilters,
}) {
final List<({BleService service, BleCharacteristic characteristic})> result =
[];
// Sort services: favorites first, then system services, then others
final sortedServices = sortBleServices(
services,
favoriteServices: favoriteServices,
);
for (var service in sortedServices) {
for (var char in service.characteristics) {
// Filter by properties if filters are selected
if (propertyFilters != null && propertyFilters.isNotEmpty) {
if (char.properties.any((prop) => propertyFilters.contains(prop))) {
result.add((service: service, characteristic: char));
}
} else {
result.add((service: service, characteristic: char));
}
}
}
return result;
}
/// Finds the next or previous characteristic in a filtered list.
///
/// [filtered] - The filtered list of (service, characteristic) tuples
/// [currentCharacteristicUuid] - The UUID of the currently selected characteristic
/// [next] - If true, finds the next item; if false, finds the previous item
///
/// Returns the next/previous item, or the first item if current is not found,
/// or null if the list is empty.
({BleService service, BleCharacteristic characteristic})?
navigateToAdjacentCharacteristic(
List<({BleService service, BleCharacteristic characteristic})> filtered,
String currentCharacteristicUuid,
bool next,
) {
if (filtered.isEmpty) return null;
final currentIndex = filtered.indexWhere(
(item) => item.characteristic.uuid == currentCharacteristicUuid,
);
if (currentIndex == -1) {
// Current selection not in filtered list, return first
return filtered.first;
}
if (next) {
// Navigate to next (with wrapping)
final nextIndex = (currentIndex + 1) % filtered.length;
return filtered[nextIndex];
} else {
// Navigate to previous (with wrapping)
final previousIndex =
currentIndex > 0 ? currentIndex - 1 : filtered.length - 1;
return filtered[previousIndex];
}
}
+356
View File
@@ -0,0 +1,356 @@
import 'dart:async';
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:universal_ble/universal_ble.dart';
import 'package:universal_ble_example/data/mock_universal_ble.dart';
import 'package:universal_ble_example/home/widgets/scan_filter_widget.dart';
import 'package:universal_ble_example/home/widgets/scanned_devices_placeholder_widget.dart';
import 'package:universal_ble_example/home/widgets/scanned_item_widget.dart';
import 'package:universal_ble_example/peripheral_details/peripheral_detail_page.dart';
import 'package:universal_ble_example/widgets/platform_button.dart';
import 'package:universal_ble_example/widgets/responsive_buttons_grid.dart';
class MyApp extends StatefulWidget {
const MyApp({super.key});
@override
State createState() => _MyAppState();
}
class _MyAppState extends State<MyApp> {
final _bleDevices = <BleDevice>[];
final _hiddenDevices = <BleDevice>[];
bool _isScanning = false;
QueueType _queueType = QueueType.global;
TextEditingController servicesFilterController = TextEditingController();
TextEditingController namePrefixController = TextEditingController();
TextEditingController manufacturerDataController = TextEditingController();
StreamSubscription<AvailabilityState>? _availabilityStreamSubscription;
bool get isTrackingAvailabilityState =>
_availabilityStreamSubscription != null;
AvailabilityState? bleAvailabilityState;
ScanFilter? scanFilter;
@override
void initState() {
super.initState();
/// Set mock instance for testing
if (const bool.fromEnvironment('MOCK')) {
UniversalBle.setInstance(MockUniversalBle());
}
/// Setup queue and timeout
UniversalBle.queueType = _queueType;
UniversalBle.timeout = const Duration(seconds: 10);
UniversalBle.scanStream.listen((result) {
// log(result.toString());
// If device is already in hidden devices, skip
if (_hiddenDevices.any((e) => e.deviceId == result.deviceId)) {
// debugPrint("Skipping hidden device: ${result.deviceId}");
return;
}
int index = _bleDevices.indexWhere((e) => e.deviceId == result.deviceId);
if (index == -1) {
_bleDevices.add(result);
} else {
if (result.name == null && _bleDevices[index].name != null) {
result.name = _bleDevices[index].name;
}
_bleDevices[index] = result;
}
setState(() {});
});
// UniversalBle.onQueueUpdate = (String id, int remainingItems) {
// debugPrint("Queue: $id RemainingItems: $remainingItems");
// };
UniversalBle.isScanning().then((value) {
debugPrint("Is Scanning: $value");
setState(() {
_isScanning = value;
});
});
}
void trackAvailabilityState() {
_availabilityStreamSubscription = UniversalBle.availabilityStream.listen(
(state) {
setState(() {
bleAvailabilityState = state;
});
},
);
setState(() {});
}
Future<void> startScan() async {
await UniversalBle.startScan(scanFilter: scanFilter);
}
Future<void> _getSystemDevices() async {
// For macOS and iOS, it is recommended to set a filter to get system devices
if ((defaultTargetPlatform == TargetPlatform.macOS ||
defaultTargetPlatform == TargetPlatform.iOS) &&
(scanFilter?.withServices ?? []).isEmpty) {
showSnackbar(
"No services filter was set for getting system connected devices. Using default services...");
}
List<BleDevice> devices = await UniversalBle.getSystemDevices(
withServices: scanFilter?.withServices,
);
if (devices.isEmpty) {
showSnackbar("No System Connected Devices Found");
}
setState(() {
_bleDevices.clear();
_bleDevices.addAll(devices);
});
}
void _showScanFilterBottomSheet() {
showModalBottomSheet(
isScrollControlled: true,
context: context,
builder: (context) {
return ScanFilterWidget(
servicesFilterController: servicesFilterController,
namePrefixController: namePrefixController,
manufacturerDataController: manufacturerDataController,
onScanFilter: (ScanFilter? filter) {
setState(() {
scanFilter = filter;
});
},
);
},
);
}
void showSnackbar(String message) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(message)),
);
}
@override
void dispose() {
_availabilityStreamSubscription?.cancel();
servicesFilterController.dispose();
namePrefixController.dispose();
manufacturerDataController.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: const Text('Universal BLE'),
elevation: 4,
actions: [
if (_isScanning)
const Padding(
padding: EdgeInsets.all(8.0),
child: SizedBox(
width: 20,
height: 20,
child: CircularProgressIndicator.adaptive(
strokeWidth: 2,
)),
),
],
),
body: Column(
children: [
Padding(
padding: const EdgeInsets.all(8.0),
child: ResponsiveButtonsGrid(
children: [
PlatformButton(
text: 'Start Scan',
onPressed: () async {
setState(() {
_bleDevices.clear();
_isScanning = true;
});
try {
await startScan();
} catch (e) {
setState(() {
_isScanning = false;
});
showSnackbar(e.toString());
}
},
),
PlatformButton(
text: 'Stop Scan',
onPressed: () async {
await UniversalBle.stopScan();
setState(() {
_isScanning = false;
});
},
),
if (BleCapabilities.supportsBluetoothEnableApi)
bleAvailabilityState != AvailabilityState.poweredOn
? PlatformButton(
text: 'Enable Bluetooth',
onPressed: () async {
bool isEnabled =
await UniversalBle.enableBluetooth();
showSnackbar("BluetoothEnabled: $isEnabled");
},
)
: PlatformButton(
text: 'Disable Bluetooth',
onPressed: () async {
bool isDisabled =
await UniversalBle.disableBluetooth();
showSnackbar("BluetoothDisabled: $isDisabled");
},
),
if (BleCapabilities.requiresRuntimePermission) ...[
PlatformButton(
text: 'Has Permissions',
onPressed: () async {
try {
bool hasPermissions = await UniversalBle.hasPermissions(
withAndroidFineLocation: false,
);
showSnackbar("Has Permissions: $hasPermissions");
} catch (e) {
showSnackbar(e.toString());
}
},
),
PlatformButton(
text: 'Request Permissions',
onPressed: () async {
try {
await UniversalBle.requestPermissions(
withAndroidFineLocation: false,
);
showSnackbar("Permissions granted");
} catch (e) {
showSnackbar(e.toString());
}
},
),
],
if (!isTrackingAvailabilityState)
PlatformButton(
text: 'Track Availability State',
onPressed: trackAvailabilityState,
),
if (BleCapabilities.supportsConnectedDevicesApi)
PlatformButton(
text: 'System Devices',
onPressed: _getSystemDevices,
),
PlatformButton(
text: 'Queue: ${_queueType.name}',
onPressed: () {
setState(() {
_queueType = switch (_queueType) {
QueueType.global => QueueType.perDevice,
QueueType.perDevice => QueueType.none,
QueueType.none => QueueType.global,
};
UniversalBle.queueType = _queueType;
});
},
),
PlatformButton(
text: 'Scan Filters',
onPressed: _showScanFilterBottomSheet,
),
if (_hiddenDevices.isNotEmpty)
PlatformButton(
text: 'Unhide ${_hiddenDevices.length} Devices',
onPressed: () {
setState(() {
_hiddenDevices.clear();
});
},
)
else if (_bleDevices.isNotEmpty)
Tooltip(
message:
'Hide already discovered devices. When you turn on a new device, it will be easier to spot.',
child: PlatformButton(
text: 'Hide Already Discovered Devices',
onPressed: () {
setState(() {
_hiddenDevices.clear();
_hiddenDevices.addAll(_bleDevices);
_bleDevices.clear();
});
},
),
),
if (_bleDevices.isNotEmpty)
PlatformButton(
text: 'Clear List',
onPressed: () {
setState(() {
_bleDevices.clear();
});
},
),
],
),
),
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
if (isTrackingAvailabilityState)
Padding(
padding: const EdgeInsets.all(8.0),
child: Text(
'Ble Availability : ${bleAvailabilityState?.name}',
),
),
],
),
const Divider(color: Colors.blue),
Expanded(
child: _isScanning && _bleDevices.isEmpty
? const Center(child: CircularProgressIndicator.adaptive())
: !_isScanning && _bleDevices.isEmpty
? const ScannedDevicesPlaceholderWidget()
: ListView.separated(
itemCount: _bleDevices.length,
separatorBuilder: (context, index) => const Divider(),
itemBuilder: (context, index) {
BleDevice device =
_bleDevices[_bleDevices.length - index - 1];
return ScannedItemWidget(
bleDevice: device,
onTap: () {
Navigator.push(
context,
MaterialPageRoute(
builder: (_) => PeripheralDetailPage(device),
),
);
UniversalBle.stopScan();
setState(() {
_isScanning = false;
});
},
);
},
),
),
],
),
);
}
}
-242
View File
@@ -1,242 +0,0 @@
import 'package:flutter/material.dart';
import 'package:universal_ble/universal_ble.dart';
import 'package:universal_ble_example/home/scanner_screen.dart';
class PermissionScreen extends StatefulWidget {
const PermissionScreen({super.key});
@override
State<PermissionScreen> createState() => _PermissionScreenState();
}
class _PermissionScreenState extends State<PermissionScreen>
with WidgetsBindingObserver {
final bool _withAndroidFineLocation = false;
bool _isChecking = true;
bool _hasPermissions = false;
bool _isRequesting = false;
String? _errorMessage;
void navigateToHome() {
if (mounted) {
Navigator.of(context).pushReplacement(
MaterialPageRoute(builder: (_) => const ScannerScreen()),
);
}
}
@override
void initState() {
super.initState();
WidgetsBinding.instance.addObserver(this);
_checkPermissions();
}
@override
void dispose() {
WidgetsBinding.instance.removeObserver(this);
super.dispose();
}
@override
void didChangeAppLifecycleState(AppLifecycleState state) {
super.didChangeAppLifecycleState(state);
// Re-check permissions when app resumes (user might have granted in settings)
if (state == AppLifecycleState.resumed && !_hasPermissions) {
_checkPermissions();
}
}
Future<void> _checkPermissions() async {
setState(() {
_isChecking = true;
_errorMessage = null;
});
try {
// Check if permissions are required for this platform
if (!BleCapabilities.requiresRuntimePermission) {
// No permissions needed, go directly to home
navigateToHome();
return;
}
// Check if we already have permissions
final hasPermissions = await UniversalBle.hasPermissions(
withAndroidFineLocation: _withAndroidFineLocation,
);
setState(() {
_isChecking = false;
_hasPermissions = hasPermissions;
});
if (hasPermissions && mounted) {
// Permissions already granted, go to home
navigateToHome();
}
} catch (e) {
setState(() {
_isChecking = false;
_errorMessage = e.toString();
});
}
}
Future<void> _requestPermissions() async {
setState(() {
_isRequesting = true;
_errorMessage = null;
});
try {
await UniversalBle.requestPermissions(
withAndroidFineLocation: _withAndroidFineLocation,
);
// Permissions granted, go to home
if (mounted) {
navigateToHome();
}
} catch (e) {
setState(() {
_isRequesting = false;
_errorMessage = e.toString();
});
}
}
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final colorScheme = theme.colorScheme;
return Scaffold(
backgroundColor: colorScheme.surface,
body: SafeArea(
child: Center(
child: Padding(
padding: const EdgeInsets.all(24.0),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
// Icon
Container(
padding: const EdgeInsets.all(32),
decoration: BoxDecoration(
color: colorScheme.primaryContainer.withValues(alpha: 0.3),
shape: BoxShape.circle,
),
child: Icon(
Icons.security,
size: 80,
color: colorScheme.primary,
),
),
const SizedBox(height: 32),
Text(
'Bluetooth Permissions Required',
style: TextStyle(
fontSize: 24,
fontWeight: FontWeight.bold,
color: colorScheme.onSurface,
),
textAlign: TextAlign.center,
),
const SizedBox(height: 16),
Text(
'This app needs Bluetooth permissions to scan and connect to nearby devices.',
style: TextStyle(
fontSize: 16,
color: colorScheme.onSurface.withValues(alpha: 0.7),
),
textAlign: TextAlign.center,
),
const SizedBox(height: 48),
if (_isChecking)
CircularProgressIndicator(
valueColor: AlwaysStoppedAnimation<Color>(
colorScheme.primary,
),
)
else if (!_hasPermissions)
Column(
children: [
ElevatedButton.icon(
onPressed: _isRequesting ? null : _requestPermissions,
icon: _isRequesting
? SizedBox(
width: 20,
height: 20,
child: CircularProgressIndicator(
strokeWidth: 2,
valueColor: AlwaysStoppedAnimation<Color>(
colorScheme.onPrimary,
),
),
)
: const Icon(Icons.check_circle),
label: Text(
_isRequesting
? 'Requesting Permissions...'
: 'Get Started',
style: const TextStyle(fontSize: 16),
),
style: ElevatedButton.styleFrom(
backgroundColor: colorScheme.primary,
foregroundColor: colorScheme.onPrimary,
padding: const EdgeInsets.symmetric(
horizontal: 32,
vertical: 16,
),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12),
),
),
),
if (_errorMessage != null) ...[
const SizedBox(height: 16),
Container(
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
color: colorScheme.errorContainer,
borderRadius: BorderRadius.circular(8),
),
child: Row(
children: [
Icon(
Icons.error_outline,
color: colorScheme.error,
size: 20,
),
const SizedBox(width: 8),
Expanded(
child: Text(
_errorMessage!,
style: TextStyle(
color: colorScheme.onErrorContainer,
fontSize: 12,
),
),
),
],
),
),
const SizedBox(height: 16),
TextButton(
onPressed: _requestPermissions,
child: const Text('Try Again'),
),
],
],
)
else
const SizedBox.shrink(),
],
),
),
),
),
);
}
}
File diff suppressed because it is too large Load Diff
-379
View File
@@ -1,379 +0,0 @@
import 'dart:async';
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:universal_ble/universal_ble.dart';
import 'package:universal_ble_example/home/widgets/ble_availability_icon.dart';
import 'package:universal_ble_example/home/widgets/drawer.dart';
import 'package:universal_ble_example/home/widgets/scanned_item_widget.dart';
import 'package:universal_ble_example/peripheral_details/peripheral_detail_page.dart';
class SystemDevicesScreen extends StatefulWidget {
const SystemDevicesScreen({super.key});
@override
State<SystemDevicesScreen> createState() => _SystemDevicesScreenState();
}
class _SystemDevicesScreenState extends State<SystemDevicesScreen> {
List<BleDevice> _systemDevices = [];
bool _isLoading = false;
AvailabilityState? bleAvailabilityState;
List<String> withServices = [];
final Map<String, bool> _isExpanded = {};
final TextEditingController _servicesController = TextEditingController();
void _parseServices() {
setState(() {
withServices = [];
if (_servicesController.text.isNotEmpty) {
final services = _servicesController.text
.split(',')
.map((s) => s.trim())
.where((s) => s.isNotEmpty)
.toList();
for (String service in services) {
try {
withServices.add(BleUuidParser.string(service));
} catch (e) {
_showSnackbar("Invalid Service UUID: $service");
return;
}
}
}
});
}
Future<void> _getSystemDevices() async {
_parseServices();
// For macOS and iOS, it is recommended to set a filter to get system devices
if ((defaultTargetPlatform == TargetPlatform.macOS ||
defaultTargetPlatform == TargetPlatform.iOS) &&
withServices.isEmpty) {
_showSnackbar(
"No services filter was set for getting system connected devices. Using default services...",
);
}
setState(() {
_isLoading = true;
});
try {
List<BleDevice> devices = await UniversalBle.getSystemDevices(
withServices: withServices,
);
setState(() {
_systemDevices = devices;
_isLoading = false;
});
if (devices.isEmpty) {
_showSnackbar("No System Connected Devices Found");
}
} catch (e) {
setState(() {
_isLoading = false;
});
_showSnackbar(e.toString());
}
}
void _showSnackbar(String message) {
ScaffoldMessenger.of(context)
.showSnackBar(SnackBar(content: Text(message)));
}
bool get _isBluetoothAvailable =>
bleAvailabilityState == AvailabilityState.poweredOn;
@override
void setState(VoidCallback fn) {
if (mounted) super.setState(fn);
}
@override
void dispose() {
_servicesController.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final colorScheme = theme.colorScheme;
return Scaffold(
backgroundColor: colorScheme.surface,
drawer: const AppDrawer(),
appBar: AppBar(
title: Row(
children: [
BleAvailabilityIcon(onAvailabilityStateChanged: (state) {
setState(() => bleAvailabilityState = state);
}),
const SizedBox(width: 12),
Expanded(
child: const Text(
'System Devices',
style: TextStyle(
fontWeight: FontWeight.bold,
letterSpacing: 0.5,
),
),
),
],
),
elevation: 0,
backgroundColor: colorScheme.surface,
actions: [
IconButton(
icon: Icon(
Icons.refresh,
color: colorScheme.primary,
),
tooltip: 'Refresh',
onPressed: _isBluetoothAvailable ? _getSystemDevices : null,
),
],
),
body: Column(
children: [
// Services input text box
Padding(
padding: const EdgeInsets.all(16),
child: Card(
elevation: 2,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12),
),
child: Padding(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Icon(
Icons.apps,
color: colorScheme.primary,
size: 20,
),
const SizedBox(width: 8),
Text(
'Service UUIDs (Optional)',
style: TextStyle(
fontWeight: FontWeight.bold,
fontSize: 16,
color: colorScheme.onSurface,
),
),
],
),
const SizedBox(height: 8),
Text(
'Enter service UUIDs separated by commas. Leave empty to use default services.\nMandatory on Apple platforms.',
style: TextStyle(
fontSize: 12,
color: colorScheme.onSurface.withValues(alpha: 0.7),
),
),
const SizedBox(height: 12),
TextField(
controller: _servicesController,
maxLines: 8,
minLines: 4,
style: const TextStyle(
fontFamily: 'monospace',
fontSize: 12,
),
decoration: InputDecoration(
hintText:
'eg: 0000180f-0000-1000-8000-00805f9b34fb, 0000180a-0000-1000-8000-00805f9b34fb',
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(8),
),
filled: true,
fillColor: colorScheme.surfaceContainerHighest,
contentPadding: const EdgeInsets.all(12),
),
),
],
),
),
),
),
Padding(
padding: const EdgeInsets.symmetric(horizontal: 16),
child: SizedBox(
width: double.infinity,
child: ElevatedButton.icon(
onPressed: _isBluetoothAvailable && !_isLoading
? _getSystemDevices
: null,
icon: _isLoading
? SizedBox(
width: 20,
height: 20,
child: CircularProgressIndicator(
strokeWidth: 2,
valueColor: AlwaysStoppedAnimation<Color>(
colorScheme.onPrimary,
),
),
)
: const Icon(Icons.search),
label: Text(_isLoading ? 'Loading...' : 'Get System Devices'),
style: ElevatedButton.styleFrom(
backgroundColor: colorScheme.primary,
foregroundColor: colorScheme.onPrimary,
padding: const EdgeInsets.symmetric(vertical: 16),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12),
),
elevation: 2,
),
),
),
),
if (_systemDevices.isNotEmpty)
Padding(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
child: Row(
children: [
Container(
padding: const EdgeInsets.symmetric(
horizontal: 12,
vertical: 6,
),
decoration: BoxDecoration(
color: colorScheme.primaryContainer,
borderRadius: BorderRadius.circular(12),
),
child: Text(
'${_systemDevices.length} device${_systemDevices.length == 1 ? '' : 's'}',
style: TextStyle(
color: colorScheme.onPrimaryContainer,
fontSize: 12,
fontWeight: FontWeight.w600,
),
),
),
],
),
),
const Divider(),
Expanded(
child: !_isBluetoothAvailable
? Center(
child: Padding(
padding: const EdgeInsets.all(24.0),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Container(
padding: const EdgeInsets.all(32),
decoration: BoxDecoration(
color: colorScheme.errorContainer.withValues(
alpha: 0.3,
),
shape: BoxShape.circle,
),
child: Icon(
Icons.bluetooth_disabled,
size: 80,
color: colorScheme.error,
),
),
const SizedBox(height: 32),
Text(
'Bluetooth is Turned Off',
style: TextStyle(
fontSize: 24,
fontWeight: FontWeight.bold,
color: colorScheme.onSurface,
),
textAlign: TextAlign.center,
),
const SizedBox(height: 16),
Text(
'Please turn on Bluetooth to get system devices',
style: TextStyle(
fontSize: 16,
color: colorScheme.onSurface.withValues(
alpha: 0.7,
),
),
textAlign: TextAlign.center,
),
],
),
),
)
: _systemDevices.isEmpty
? Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(
Icons.devices_outlined,
size: 64,
color:
colorScheme.onSurface.withValues(alpha: 0.3),
),
const SizedBox(height: 16),
Text(
'No system devices found',
style: TextStyle(
color: colorScheme.onSurface
.withValues(alpha: 0.6),
fontSize: 16,
),
),
const SizedBox(height: 8),
Text(
'Click "Get System Devices" to refresh',
style: TextStyle(
color: colorScheme.onSurface
.withValues(alpha: 0.5),
fontSize: 14,
),
),
],
),
)
: ListView.separated(
padding: const EdgeInsets.symmetric(
horizontal: 16,
vertical: 8,
),
itemCount: _systemDevices.length,
separatorBuilder: (context, index) =>
const SizedBox(height: 8),
itemBuilder: (context, index) {
BleDevice device = _systemDevices[index];
return ScannedItemWidget(
bleDevice: device,
isExpanded: _isExpanded[device.deviceId] ?? false,
onExpand: (isExpanded) {
setState(() {
_isExpanded[device.deviceId] = isExpanded;
});
},
onTap: () {
Navigator.push(
context,
MaterialPageRoute(
builder: (_) => PeripheralDetailPage(device),
),
);
},
);
},
),
),
],
),
);
}
}
@@ -1,73 +0,0 @@
import 'dart:async';
import 'package:flutter/material.dart';
import 'package:universal_ble/universal_ble.dart';
class BleAvailabilityIcon extends StatefulWidget {
final Function(AvailabilityState) onAvailabilityStateChanged;
const BleAvailabilityIcon({
super.key,
required this.onAvailabilityStateChanged,
});
@override
State<BleAvailabilityIcon> createState() => _BleAvailabilityIconState();
}
class _BleAvailabilityIconState extends State<BleAvailabilityIcon> {
AvailabilityState? bleAvailabilityState;
StreamSubscription<AvailabilityState>? _availabilitySubscription;
@override
void initState() {
super.initState();
UniversalBle.getBluetoothAvailabilityState()
.then(_handleAvailabilityStateChanged);
_availabilitySubscription =
UniversalBle.availabilityStream.listen(_handleAvailabilityStateChanged);
}
void _handleAvailabilityStateChanged(AvailabilityState state) {
if (mounted) setState(() => bleAvailabilityState = state);
widget.onAvailabilityStateChanged(state);
}
@override
void dispose() {
_availabilitySubscription?.cancel();
super.dispose();
}
@override
Widget build(BuildContext context) {
if (bleAvailabilityState == null) {
return const SizedBox.shrink();
}
return switch (bleAvailabilityState!) {
AvailabilityState.resetting => Icon(
Icons.bluetooth_searching,
color: Theme.of(context).colorScheme.onSurface.withValues(alpha: 0.6),
),
AvailabilityState.poweredOn => Icon(
Icons.bluetooth_connected,
color: Theme.of(context).colorScheme.primary,
),
AvailabilityState.poweredOff => Icon(
Icons.bluetooth_disabled,
color: Theme.of(context).colorScheme.error,
),
AvailabilityState.unauthorized => Icon(
Icons.bluetooth_disabled,
color: Theme.of(context).colorScheme.error,
),
AvailabilityState.unsupported => Icon(
Icons.bluetooth_disabled,
color: Theme.of(context).colorScheme.outline,
),
AvailabilityState.unknown => Icon(
Icons.bluetooth_searching,
color: Theme.of(context).colorScheme.onSurface.withValues(alpha: 0.6),
),
};
}
}
@@ -1,30 +0,0 @@
import 'package:flutter/material.dart';
import 'package:universal_ble/universal_ble.dart';
class BleDeviceInfoWidget extends StatelessWidget {
final BleDevice bleDevice;
const BleDeviceInfoWidget({super.key, required this.bleDevice});
@override
Widget build(BuildContext context) {
return Padding(
padding: const EdgeInsets.symmetric(horizontal: 16.0, vertical: 8.0),
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
SelectableText('Device ID: ${bleDevice.deviceId}'),
SelectableText('Name: ${bleDevice.name ?? 'N/A'}'),
SelectableText('RSSI: ${bleDevice.rssi}'),
SelectableText('Paired: ${bleDevice.paired}'),
SelectableText(
'Services: ${bleDevice.services.isNotEmpty ? bleDevice.services.join(', ') : 'N/A'}',
),
SelectableText(
'Manufacturer Data: ${bleDevice.manufacturerDataList.isNotEmpty ? bleDevice.manufacturerDataList.map((e) => e.toString()).join(', ') : 'N/A'}',
),
],
),
);
}
}
-307
View File
@@ -1,307 +0,0 @@
import 'package:flutter/gestures.dart';
import 'package:flutter/material.dart';
import 'package:package_info_plus/package_info_plus.dart';
import 'package:universal_ble/universal_ble.dart';
import 'package:universal_ble_example/home/widgets/ble_availability_icon.dart';
import 'package:universal_ble_example/home/scanner_screen.dart';
import 'package:universal_ble_example/home/system_devices_screen.dart';
import 'package:url_launcher/url_launcher.dart';
class AppDrawer extends StatefulWidget {
final QueueType? queueType;
final Function(QueueType)? onQueueTypeChanged;
final AvailabilityState? availabilityState;
final void Function(AvailabilityState)? onAvailabilityStateChanged;
const AppDrawer({
super.key,
this.queueType,
this.onQueueTypeChanged,
this.availabilityState,
this.onAvailabilityStateChanged,
});
@override
State<AppDrawer> createState() => _AppDrawerState();
}
class _AppDrawerState extends State<AppDrawer> {
void _navigateToScreen(BuildContext context, Widget screen) {
Navigator.pop(context);
Navigator.pushReplacement(
context,
MaterialPageRoute(builder: (_) => screen),
);
}
@override
Widget build(BuildContext context) {
final colorScheme = Theme.of(context).colorScheme;
return Drawer(
child: Column(
children: [
SizedBox(
height: 110,
child: DrawerHeader(
padding: const EdgeInsets.only(top: 0, left: 16),
decoration: BoxDecoration(
color: colorScheme.primary,
),
child: Row(
children: [
Image.asset('assets/icon.png', width: 40, height: 40),
const SizedBox(width: 10),
Expanded(
child: Text(
'Universal BLE',
maxLines: 2,
style:
Theme.of(context).textTheme.headlineMedium?.copyWith(
fontWeight: FontWeight.bold,
color: colorScheme.onPrimary,
height: 1,
),
),
),
],
),
),
),
if (widget.availabilityState != null &&
widget.onAvailabilityStateChanged != null)
ListTile(
leading: BleAvailabilityIcon(
onAvailabilityStateChanged: widget.onAvailabilityStateChanged!,
),
title: Text(_getBluetoothAvailabilityLabel(widget.availabilityState!)),
),
ListTile(
leading: const Icon(Icons.search),
title: const Text('Scanner'),
onTap: () => _navigateToScreen(context, const ScannerScreen()),
),
if (BleCapabilities.supportsConnectedDevicesApi)
ListTile(
leading: const Icon(Icons.devices),
title: const Text('System Devices'),
onTap: () =>
_navigateToScreen(context, const SystemDevicesScreen()),
),
if (widget.queueType != null && widget.onQueueTypeChanged != null)
ExpansionTile(
leading: const Icon(Icons.queue),
title: const Text('Queue Type'),
subtitle: Text(
_getQueueTypeLabel(widget.queueType!),
style: TextStyle(
fontSize: 12,
color: colorScheme.onSurface.withValues(alpha: 0.6),
),
),
children: [
Padding(
padding:
const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
child: Column(
children: [
_buildQueueOption(
context,
QueueType.global,
'Global',
'All commands from all devices execute sequentially in a single queue',
Icons.queue,
),
const SizedBox(height: 8),
_buildQueueOption(
context,
QueueType.perDevice,
'Per Device',
'Commands for each device execute in separate queues',
Icons.devices,
),
const SizedBox(height: 8),
_buildQueueOption(
context,
QueueType.none,
'None',
'All commands execute in parallel without queuing',
Icons.all_inclusive,
),
],
),
),
],
),
FutureBuilder(
future: PackageInfo.fromPlatform(),
builder: (_, snapshot) {
final theme = Theme.of(context);
final bodyStyle = theme.textTheme.bodyMedium?.copyWith(
color: theme.colorScheme.onSurface,
) ?? TextStyle(color: theme.colorScheme.onSurface);
final linkStyle = bodyStyle.copyWith(
color: theme.colorScheme.primary,
decoration: TextDecoration.underline,
);
return AboutListTile(
icon: const Icon(Icons.info_outline),
applicationIcon:
Image.asset('assets/icon.png', width: 40, height: 40),
applicationName: 'Universal BLE',
applicationVersion:
"${snapshot.data?.version} (${snapshot.data?.buildNumber})",
applicationLegalese: '\u{a9} 2023 Navideck',
aboutBoxChildren: [
const SizedBox(height: 24),
RichText(
textAlign: TextAlign.justify,
text: TextSpan(
style: bodyStyle,
children: [
const TextSpan(text: "Universal BLE is "),
TextSpan(
text: 'open source',
style: linkStyle,
recognizer: TapGestureRecognizer()
..onTap = () {
launchUrl(Uri.parse(
"https://github.com/Navideck/Universal-BLE"));
},
),
const TextSpan(text: "."),
],
),
),
const SizedBox(height: 24),
RichText(
textAlign: TextAlign.justify,
text: TextSpan(
style: bodyStyle,
children: [
const TextSpan(text: "Need help with your project? "),
TextSpan(
text: 'Hire us',
style: linkStyle,
recognizer: TapGestureRecognizer()
..onTap = () {
launchUrl(Uri.parse('https://navideck.com/contact'));
},
),
],
),
),
],
);
},
),
],
),
);
}
String _getBluetoothAvailabilityLabel(AvailabilityState state) {
switch (state) {
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 state unknown';
}
}
String _getQueueTypeLabel(QueueType queueType) {
switch (queueType) {
case QueueType.global:
return 'Global';
case QueueType.perDevice:
return 'Per Device';
case QueueType.none:
return 'None';
}
}
Widget _buildQueueOption(
BuildContext context,
QueueType value,
String title,
String description,
IconData icon,
) {
final colorScheme = Theme.of(context).colorScheme;
final isSelected = widget.queueType == value;
return InkWell(
onTap: () {
widget.onQueueTypeChanged?.call(value);
Navigator.pop(context);
},
borderRadius: BorderRadius.circular(12),
child: Container(
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
color: isSelected
? colorScheme.primaryContainer
: colorScheme.surfaceContainerHighest,
borderRadius: BorderRadius.circular(12),
border: Border.all(
color: isSelected
? colorScheme.primary
: colorScheme.outline.withValues(alpha: 0.2),
width: isSelected ? 2 : 1,
),
),
child: Row(
children: [
Icon(
icon,
color: isSelected
? colorScheme.onPrimaryContainer
: colorScheme.onSurface,
),
const SizedBox(width: 16),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
title,
style: TextStyle(
fontWeight: FontWeight.bold,
fontSize: 14,
color: isSelected
? colorScheme.onPrimaryContainer
: colorScheme.onSurface,
),
),
const SizedBox(height: 4),
Text(
description,
style: TextStyle(
fontSize: 11,
color: isSelected
? colorScheme.onPrimaryContainer
.withValues(alpha: 0.7)
: colorScheme.onSurface.withValues(alpha: 0.6),
),
),
],
),
),
if (isSelected)
Icon(
Icons.check_circle,
color: colorScheme.primary,
size: 20,
),
],
),
),
);
}
}
@@ -1,157 +0,0 @@
import 'package:flutter/material.dart';
import 'package:universal_ble/universal_ble.dart';
class QueueSelectorWidget extends StatelessWidget {
final QueueType queueType;
final Function(QueueType) onQueueTypeChanged;
const QueueSelectorWidget({
super.key,
required this.queueType,
required this.onQueueTypeChanged,
});
@override
Widget build(BuildContext context) {
final colorScheme = Theme.of(context).colorScheme;
return Container(
padding: const EdgeInsets.all(24),
child: SingleChildScrollView(
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Icon(
Icons.queue,
color: colorScheme.primary,
size: 28,
),
const SizedBox(width: 12),
Expanded(
child: Text(
"Queue Type",
style: Theme.of(context).textTheme.titleLarge?.copyWith(
fontWeight: FontWeight.bold,
),
),
),
IconButton(
icon: const Icon(Icons.close),
onPressed: () => Navigator.pop(context),
tooltip: 'Close',
),
],
),
const SizedBox(height: 2),
Text(
"Controls how BLE commands are executed.",
style: Theme.of(context).textTheme.bodySmall?.copyWith(
color: colorScheme.onSurface.withValues(alpha: 0.6),
),
),
const SizedBox(height: 8),
_buildQueueOption(
context,
QueueType.global,
'Global',
'All commands from all devices execute sequentially in a single queue',
Icons.queue,
),
const SizedBox(height: 8),
_buildQueueOption(
context,
QueueType.perDevice,
'Per Device',
'Commands for each device execute in separate queues',
Icons.devices,
),
const SizedBox(height: 8),
_buildQueueOption(
context,
QueueType.none,
'None',
'All commands execute in parallel without queuing',
Icons.all_inclusive,
),
const SizedBox(height: 8),
],
),
),
);
}
Widget _buildQueueOption(
BuildContext context,
QueueType value,
String title,
String description,
IconData icon,
) {
final colorScheme = Theme.of(context).colorScheme;
final isSelected = queueType == value;
return InkWell(
onTap: () => onQueueTypeChanged(value),
borderRadius: BorderRadius.circular(12),
child: Container(
padding: const EdgeInsets.all(6),
decoration: BoxDecoration(
color: isSelected
? colorScheme.primaryContainer
: colorScheme.surfaceContainerHighest,
borderRadius: BorderRadius.circular(12),
border: Border.all(
color: isSelected
? colorScheme.primary
: colorScheme.outline.withValues(alpha: 0.2),
width: isSelected ? 2 : 1,
),
),
child: Row(
children: [
Icon(
icon,
color: isSelected
? colorScheme.onPrimaryContainer
: colorScheme.onSurface,
),
const SizedBox(width: 16),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
title,
style: TextStyle(
fontWeight: FontWeight.bold,
fontSize: 16,
color: isSelected
? colorScheme.onPrimaryContainer
: colorScheme.onSurface,
),
),
const SizedBox(height: 4),
Text(
description,
style: TextStyle(
fontSize: 12,
color: isSelected
? colorScheme.onPrimaryContainer
.withValues(alpha: 0.7)
: colorScheme.onSurface.withValues(alpha: 0.6),
),
),
],
),
),
if (isSelected)
Icon(
Icons.check_circle,
color: colorScheme.primary,
),
],
),
),
);
}
}
@@ -1,63 +0,0 @@
import 'package:flutter/material.dart';
class RssiSignalIndicator extends StatelessWidget {
final int rssi;
const RssiSignalIndicator({super.key, required this.rssi});
@override
Widget build(BuildContext context) {
final bool isPositive = rssi >= 0;
final Color barColor = isPositive ? Colors.green : Colors.red;
// BLE RSSI ranges (in dBm):
// -30 to 0: Excellent signal (4 bars)
// -50 to -30: Good signal (3 bars)
// -70 to -50: Fair signal (2 bars)
// -90 to -70: Weak signal (1 bar)
// Below -90: Very weak signal (0 bars)
// Positive values: Extremely strong (4 bars)
int bars;
if (isPositive) {
bars = 4; // Positive RSSI is extremely strong
} else {
if (rssi >= -30) {
bars = 4; // Excellent
} else if (rssi >= -50) {
bars = 3; // Good
} else if (rssi >= -70) {
bars = 2; // Fair
} else if (rssi >= -90) {
bars = 1; // Weak
} else {
bars = 0; // Very weak
}
}
return Column(
crossAxisAlignment: CrossAxisAlignment.center,
mainAxisAlignment: MainAxisAlignment.center,
children: [
SizedBox(
width: 24,
height: 16,
child: Row(
crossAxisAlignment: CrossAxisAlignment.end,
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: List.generate(4, (index) {
final barHeight = (index + 1) * 3.0 + 2.0;
final isActive = index < bars;
return Container(
width: 3,
height: barHeight,
decoration: BoxDecoration(
color: isActive ? barColor : Colors.grey.withAlpha(30),
borderRadius: BorderRadius.circular(1.5),
),
);
}),
),
),
const SizedBox(height: 4),
Text('$rssi'),
],
);
}
}
+75 -432
View File
@@ -1,18 +1,12 @@
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:universal_ble/universal_ble.dart';
import 'package:universal_ble_example/data/company_identifier_service.dart';
import 'package:universal_ble_example/data/saved_scan_filter.dart';
import 'package:universal_ble_example/widgets/platform_button.dart';
class ScanFilterWidget extends StatefulWidget {
final void Function(ScanFilter? filter) onScanFilter;
final TextEditingController servicesFilterController;
final TextEditingController namePrefixController;
final TextEditingController manufacturerDataController;
final ValueListenable<List<SavedScanFilter>> savedFiltersListenable;
final void Function(SavedScanFilter preset) onSelectSavedFilter;
final void Function(SavedScanFilter preset, String newName) onRenameFilter;
final void Function(SavedScanFilter preset) onDeleteFilter;
const ScanFilterWidget({
super.key,
@@ -20,10 +14,6 @@ class ScanFilterWidget extends StatefulWidget {
required this.servicesFilterController,
required this.namePrefixController,
required this.manufacturerDataController,
required this.savedFiltersListenable,
required this.onSelectSavedFilter,
required this.onRenameFilter,
required this.onDeleteFilter,
});
@override
@@ -33,72 +23,18 @@ class ScanFilterWidget extends StatefulWidget {
class _ScanFilterWidgetState extends State<ScanFilterWidget> {
String? error;
int? _selectedSavedFilterIndex(List<SavedScanFilter> savedFilters) {
final s = widget.servicesFilterController.text;
final n = widget.namePrefixController.text;
final m = widget.manufacturerDataController.text;
for (var i = 0; i < savedFilters.length; i++) {
final f = savedFilters[i];
if (f.services == s && f.namePrefix == n && f.manufacturerData == m) {
return i;
}
}
return null;
}
Future<void> _showRenameDialog(
BuildContext context, SavedScanFilter preset) async {
final controller = TextEditingController(text: preset.name);
final newName = await showDialog<String>(
context: context,
builder: (ctx) {
return AlertDialog(
title: const Text('Rename filter'),
content: TextField(
controller: controller,
decoration: const InputDecoration(
labelText: 'Name',
border: OutlineInputBorder(),
),
autofocus: true,
onSubmitted: (v) => Navigator.pop(ctx, v),
),
actions: [
TextButton(
onPressed: () => Navigator.pop(ctx),
child: const Text('Cancel'),
),
FilledButton(
onPressed: () => Navigator.pop(ctx, controller.text.trim()),
child: const Text('Save'),
),
],
);
},
);
if (newName != null && newName.isNotEmpty && mounted) {
widget.onRenameFilter(preset, newName);
}
}
void applyFilter() async {
void applyFilter() {
setState(() {
error = null;
});
try {
// Ensure company identifier service is loaded
await CompanyIdentifierService.instance.load();
List<String> serviceUUids = [];
List<String> namePrefixes = [];
List<ManufacturerDataFilter> manufacturerDataFilters = [];
// Parse Services - handle both comma and newline separated
// Parse Services
if (widget.servicesFilterController.text.isNotEmpty) {
List<String> services = widget.servicesFilterController.text
.split(',')
.map((e) => e.trim())
.where((s) => s.isNotEmpty)
.toList();
List<String> services = widget.servicesFilterController.text.split(',');
for (String service in services) {
try {
serviceUUids.add(BleUuidParser.string(service.trim()));
@@ -108,28 +44,20 @@ class _ScanFilterWidgetState extends State<ScanFilterWidget> {
}
}
// Parse Name Prefix - handle both comma and newline separated
// Parse Name Prefix
String namePrefix = widget.namePrefixController.text;
if (namePrefix.isNotEmpty) {
namePrefixes = namePrefix.split(',').map((e) => e.trim()).toList();
}
// Parse Manufacturer Data - handle both comma and newline separated
// Parse Manufacturer Data
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();
List<String> manufacturerData = manufacturerDataText.split(',');
for (String manufacturer in manufacturerData) {
final companyIdentifier =
companyService.parseCompanyIdentifier(manufacturer);
int? companyIdentifier = int.tryParse(manufacturer);
if (companyIdentifier == null) {
throw Exception(
"Invalid Manufacturer Data or Company Name: $manufacturer");
throw Exception("Invalid Manufacturer Data $manufacturer");
}
manufacturerDataFilters.add(
ManufacturerDataFilter(companyIdentifier: companyIdentifier));
@@ -148,15 +76,11 @@ class _ScanFilterWidgetState extends State<ScanFilterWidget> {
withManufacturerData: manufacturerDataFilters,
),
);
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text("Filters Applied")),
);
}
}
if (mounted) {
Navigator.pop(context);
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text("Filters Applied")),
);
}
Navigator.pop(context);
} catch (e) {
setState(() {
error = e.toString();
@@ -174,355 +98,74 @@ class _ScanFilterWidgetState extends State<ScanFilterWidget> {
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final colorScheme = theme.colorScheme;
return Container(
decoration: BoxDecoration(
color: colorScheme.surface,
borderRadius: const BorderRadius.vertical(top: Radius.circular(20)),
),
child: SafeArea(
child: Padding(
padding: EdgeInsets.only(
left: 20,
right: 20,
top: 20,
bottom: MediaQuery.of(context).viewInsets.bottom + 20,
),
child: SingleChildScrollView(
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
// Header
Row(
children: [
Icon(
Icons.filter_list,
color: colorScheme.primary,
size: 28,
),
const SizedBox(width: 12),
Expanded(
child: Text(
"Scan Filters",
style: theme.textTheme.titleLarge?.copyWith(
fontWeight: FontWeight.bold,
),
),
),
IconButton(
icon: const Icon(Icons.close),
onPressed: () => Navigator.pop(context),
tooltip: 'Close',
),
],
),
Text(
"Filter devices by name, services, or manufacturer data. Enter multiple values separated by commas.",
style: theme.textTheme.bodySmall?.copyWith(
color: colorScheme.onSurface.withValues(alpha: 0.6),
),
),
const SizedBox(height: 8),
if (error != null)
Container(
margin: const EdgeInsets.only(bottom: 16),
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
color: colorScheme.errorContainer,
borderRadius: BorderRadius.circular(8),
border: Border.all(
color: colorScheme.error.withValues(alpha: 0.3),
),
),
child: Row(
children: [
Icon(
Icons.error_outline,
color: colorScheme.error,
size: 20,
),
const SizedBox(width: 12),
Expanded(
child: Text(
error!,
style: TextStyle(
color: colorScheme.onErrorContainer,
fontSize: 13,
),
),
),
],
),
),
// Saved filters dropdown (rebuilds when list changes)
ValueListenableBuilder<List<SavedScanFilter>>(
valueListenable: widget.savedFiltersListenable,
builder: (context, savedFilters, child) {
if (savedFilters.isEmpty) {
return const SizedBox.shrink();
}
final selectedIndex =
_selectedSavedFilterIndex(savedFilters);
final selectedPreset = selectedIndex != null &&
selectedIndex < savedFilters.length
? savedFilters[selectedIndex]
: null;
// With a single filter, always allow edit/delete even if not selected in dropdown
final presetForActions = selectedPreset ??
(savedFilters.length == 1 ? savedFilters[0] : null);
return Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Text(
'Saved filters',
style: theme.textTheme.titleSmall?.copyWith(
fontWeight: FontWeight.w600,
color: colorScheme.primary,
),
),
const SizedBox(height: 8),
Row(
children: [
Expanded(
child: DropdownButtonFormField<int>(
initialValue: selectedIndex,
decoration: InputDecoration(
isDense: true,
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(8),
),
contentPadding: const EdgeInsets.symmetric(
horizontal: 12,
vertical: 10,
),
),
hint: const Text(''),
items: [
const DropdownMenuItem<int>(
value: null,
child: Text(''),
),
...List.generate(
savedFilters.length,
(i) => DropdownMenuItem<int>(
value: i,
child: Text(
savedFilters[i].name,
overflow: TextOverflow.ellipsis,
),
),
),
],
onChanged: (int? index) {
if (index != null &&
index < savedFilters.length) {
widget.onSelectSavedFilter(
savedFilters[index]);
}
},
),
),
if (presetForActions != null) ...[
const SizedBox(width: 8),
IconButton(
icon: const Icon(Icons.edit_outlined),
onPressed: () => _showRenameDialog(
context, presetForActions),
tooltip: 'Rename',
style: IconButton.styleFrom(
minimumSize: const Size(36, 36),
),
),
IconButton(
icon: const Icon(Icons.delete_outline),
onPressed: () =>
widget.onDeleteFilter(presetForActions),
tooltip: 'Delete',
style: IconButton.styleFrom(
minimumSize: const Size(36, 36),
),
),
],
],
),
const SizedBox(height: 16),
],
);
},
),
// Name Prefix Filter
_buildFilterCard(
context: context,
title: "Name Prefixes",
icon: Icons.text_fields,
controller: widget.namePrefixController,
hintText: "e.g. MyDevice Sensor",
helperText: "Device names starting with these prefixes",
),
const SizedBox(height: 16),
// Services Filter
_buildFilterCard(
context: context,
title: "Service UUIDs",
maxLines: 3,
icon: Icons.apps,
controller: widget.servicesFilterController,
hintText: "e.g. 0000180f-0000-1000-8000-00805f9b34fb,180F",
helperText: "Service UUIDs (16-bit, 32-bit, or 128-bit)",
),
const SizedBox(height: 16),
// Manufacturer Data Filter
_buildFilterCard(
context: context,
title: "Manufacturer Company IDs",
icon: Icons.business,
controller: widget.manufacturerDataController,
hintText: "e.g. 76,0x004C,Apple, Inc.",
helperText:
"Company identifiers (decimal/hex) or company names",
),
const SizedBox(height: 24),
// Action Buttons
Row(
children: [
Expanded(
flex: 2,
child: OutlinedButton.icon(
onPressed: clearFilter,
icon: const Icon(Icons.clear),
label: const Text('Clear All'),
style: OutlinedButton.styleFrom(
foregroundColor: colorScheme.onSurface,
padding: const EdgeInsets.symmetric(vertical: 14),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12),
),
),
),
),
const SizedBox(width: 5),
Expanded(
flex: 3,
child: ElevatedButton.icon(
onPressed: applyFilter,
icon: const Icon(Icons.check),
label: const Text('Apply Filters'),
style: ElevatedButton.styleFrom(
backgroundColor: colorScheme.primary,
foregroundColor: colorScheme.onPrimary,
padding: const EdgeInsets.symmetric(vertical: 14),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12),
),
),
),
),
],
),
const SizedBox(height: 8),
],
return Padding(
padding: MediaQuery.of(context).viewInsets.copyWith(left: 20, right: 20),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
const SizedBox(height: 20),
Center(
child: Text(
"Scan Filters",
style: Theme.of(context).textTheme.titleLarge,
),
),
),
),
);
}
Widget _buildFilterCard({
required BuildContext context,
required String title,
required IconData icon,
required TextEditingController controller,
required String hintText,
required String helperText,
int maxLines = 2,
}) {
final theme = Theme.of(context);
final colorScheme = theme.colorScheme;
return Card(
elevation: 0,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12),
side: BorderSide(
color: colorScheme.outline.withValues(alpha: 0.2),
),
),
child: Padding(
padding: const EdgeInsets.all(8),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Icon(
icon,
size: 20,
color: colorScheme.primary,
),
const SizedBox(width: 8),
Expanded(
child: Text(
title,
style: theme.textTheme.titleSmall?.copyWith(
fontWeight: FontWeight.w600,
),
),
),
],
const Text("Use comma to add multiple values"),
const Divider(),
const SizedBox(height: 10),
TextFormField(
controller: widget.namePrefixController,
maxLines: 2,
decoration: const InputDecoration(
labelText: "Name Prefixes",
border: OutlineInputBorder(),
),
const SizedBox(height: 4),
TextFormField(
controller: controller,
maxLines: maxLines,
style: const TextStyle(fontFamily: 'monospace'),
decoration: InputDecoration(
hintText: hintText,
hintStyle: TextStyle(
color: colorScheme.onSurface.withValues(alpha: 0.15),
),
helperText: helperText,
helperMaxLines: 2,
helperStyle: TextStyle(
fontSize: 11,
color: colorScheme.onSurface.withValues(alpha: 0.6),
),
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(8),
borderSide: BorderSide(
color: colorScheme.outline.withValues(alpha: 0.5),
),
),
enabledBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(8),
borderSide: BorderSide(
color: colorScheme.outline.withValues(alpha: 0.3),
),
),
focusedBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(8),
borderSide: BorderSide(
color: colorScheme.primary,
width: 2,
),
),
filled: true,
fillColor:
colorScheme.surfaceContainerHighest.withValues(alpha: 0.3),
contentPadding: const EdgeInsets.symmetric(
horizontal: 8,
vertical: 8,
),
const SizedBox(height: 10),
TextFormField(
controller: widget.servicesFilterController,
maxLines: 2,
decoration: const InputDecoration(
labelText: "Services",
border: OutlineInputBorder(),
),
),
const SizedBox(height: 10),
TextFormField(
controller: widget.manufacturerDataController,
maxLines: 2,
decoration: const InputDecoration(
labelText: "Manufacturer Data Company IDs",
border: OutlineInputBorder(),
),
),
const SizedBox(height: 10),
Row(
mainAxisAlignment: MainAxisAlignment.spaceAround,
children: [
Expanded(
child: PlatformButton(
text: 'Apply',
onPressed: applyFilter,
),
),
const SizedBox(width: 10),
Expanded(
child: PlatformButton(
text: 'Clear',
onPressed: clearFilter,
),
),
],
),
const SizedBox(height: 10),
if (error != null)
Text(
error!,
style: const TextStyle(color: Colors.red),
),
],
),
const SizedBox(height: 10),
],
),
);
}
@@ -1,79 +1,26 @@
import 'package:flutter/material.dart';
class ScannedDevicesPlaceholderWidget extends StatelessWidget {
final Function() onTap;
const ScannedDevicesPlaceholderWidget({super.key, required this.onTap});
const ScannedDevicesPlaceholderWidget({super.key});
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final colorScheme = theme.colorScheme;
return Center(
child: SingleChildScrollView(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Container(
padding: const EdgeInsets.all(32),
decoration: BoxDecoration(
color: colorScheme.primaryContainer.withValues(alpha: 0.3),
shape: BoxShape.circle,
),
child: Icon(
Icons.bluetooth_searching,
size: 80,
color: colorScheme.primary.withValues(alpha: 0.6),
),
),
const SizedBox(height: 32),
Text(
'No Devices Found',
style: TextStyle(
color: colorScheme.onSurface,
fontSize: 24,
fontWeight: FontWeight.bold,
),
),
const SizedBox(height: 12),
Padding(
padding: const EdgeInsets.symmetric(horizontal: 48),
child: Text(
'Start scanning to discover nearby Bluetooth devices',
textAlign: TextAlign.center,
style: TextStyle(
color: colorScheme.onSurface.withValues(alpha: 0.7),
fontSize: 16,
),
),
),
const SizedBox(height: 32),
FilledButton.icon(
onPressed: onTap,
icon: const Icon(Icons.play_arrow, size: 24),
label: const Text(
'Scan',
style: TextStyle(
fontSize: 18,
fontWeight: FontWeight.w600,
),
),
style: FilledButton.styleFrom(
backgroundColor: colorScheme.primary,
foregroundColor: colorScheme.onPrimary,
padding: const EdgeInsets.symmetric(
horizontal: 12,
vertical: 16,
),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12),
),
elevation: 2,
),
),
],
return const Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Padding(
padding: EdgeInsets.all(8.0),
child: Icon(
Icons.bluetooth,
color: Colors.grey,
size: 100,
),
),
),
Text(
'Scan For Devices',
style: TextStyle(color: Colors.grey, fontSize: 22),
)
],
);
}
}
+34 -674
View File
@@ -1,692 +1,52 @@
import 'dart:async';
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';
import 'package:universal_ble_example/widgets/flash_on_update_widget.dart';
class ScannedItemWidget extends StatefulWidget {
class ScannedItemWidget extends StatelessWidget {
final BleDevice bleDevice;
final int adFlashTrigger;
final VoidCallback? onTap;
final bool isExpanded;
final Function(bool) onExpand;
const ScannedItemWidget({
super.key,
required this.bleDevice,
this.adFlashTrigger = 0,
this.onTap,
required this.isExpanded,
required this.onExpand,
});
@override
State<ScannedItemWidget> createState() => _ScannedItemWidgetState();
}
class _ScannedItemWidgetState extends State<ScannedItemWidget> {
Timer? _timer;
static String _formatLastAppeared(DateTime? timestamp) {
if (timestamp == null) return '';
final elapsed = DateTime.now().difference(timestamp);
if (elapsed.inSeconds < 2) return 'Actively advertising';
if (elapsed.inSeconds < 60) {
return '${elapsed.inSeconds} second${elapsed.inSeconds == 1 ? '' : 's'} ago';
}
if (elapsed.inMinutes < 60) {
return '${elapsed.inMinutes} minute${elapsed.inMinutes == 1 ? '' : 's'} ago';
}
return '${elapsed.inHours} hour${elapsed.inHours == 1 ? '' : 's'} ago';
}
@override
void initState() {
super.initState();
_timer = Timer.periodic(const Duration(seconds: 1), (_) {
if (mounted) setState(() {});
});
}
@override
void dispose() {
_timer?.cancel();
super.dispose();
}
const ScannedItemWidget({super.key, required this.bleDevice, this.onTap});
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final colorScheme = theme.colorScheme;
String? name = widget.bleDevice.name;
List<ManufacturerData> rawManufacturerData =
widget.bleDevice.manufacturerDataList;
if (name == null || name.isEmpty) name = 'Unknown Device';
final lastAppearedStr =
_formatLastAppeared(widget.bleDevice.timestampDateTime);
return FlashOnUpdateWidget(
trigger: widget.adFlashTrigger,
borderRadius: 16,
String? name = bleDevice.name;
List<ManufacturerData> rawManufacturerData = bleDevice.manufacturerDataList;
ManufacturerData? manufacturerData;
if (rawManufacturerData.isNotEmpty) {
manufacturerData = rawManufacturerData.first;
}
if (name == null || name.isEmpty) name = 'N/A';
return Padding(
padding: const EdgeInsets.symmetric(horizontal: 8.0),
child: Card(
elevation: 2,
margin: EdgeInsets.zero,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(16),
),
child: InkWell(
onTap: widget.onTap,
borderRadius: BorderRadius.circular(16),
child: Padding(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Signal indicator
Container(
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
color:
colorScheme.primaryContainer.withValues(alpha: 0.5),
borderRadius: BorderRadius.circular(12),
),
child: RssiSignalIndicator(
rssi: widget.bleDevice.rssi ?? 0),
),
const SizedBox(width: 16),
// Device info
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
crossAxisAlignment: CrossAxisAlignment.center,
children: [
Expanded(
child: Text(
name,
style: TextStyle(
fontSize: 16,
fontWeight: FontWeight.bold,
color: colorScheme.onSurface,
),
overflow: TextOverflow.ellipsis,
),
),
const SizedBox(width: 8),
// Expand/Collapse button
if (rawManufacturerData.isNotEmpty ||
widget.bleDevice.services.isNotEmpty ||
widget.bleDevice.serviceData.isNotEmpty)
Container(
padding: const EdgeInsets.symmetric(
horizontal: 4,
vertical: 4,
),
decoration: BoxDecoration(
color: colorScheme.primaryContainer
.withValues(alpha: 0.3),
borderRadius: BorderRadius.circular(8),
),
child: IconButton(
icon: Icon(
widget.isExpanded
? Icons.expand_less
: Icons.expand_more,
color: colorScheme.primary,
size: 18,
),
onPressed: () {
widget.onExpand(!widget.isExpanded);
},
tooltip: widget.isExpanded
? 'Collapse'
: 'Expand',
padding: EdgeInsets.zero,
constraints: const BoxConstraints(
minWidth: 20,
minHeight: 20,
),
),
),
],
),
const SizedBox(height: 6),
Row(
children: [
Icon(
Icons.fingerprint,
size: 12,
color:
colorScheme.onSurface.withValues(alpha: 0.6),
),
const SizedBox(width: 4),
Expanded(
child: Text(
widget.bleDevice.deviceId,
style: TextStyle(
fontSize: 12,
color: colorScheme.onSurface
.withValues(alpha: 0.7),
fontFamily: 'monospace',
),
overflow: TextOverflow.ellipsis,
),
),
],
),
const SizedBox(height: 6),
// Last seen
Row(
children: [
Icon(
Icons.ad_units_outlined,
size: 12,
color: colorScheme.primary
.withValues(alpha: 0.8),
),
const SizedBox(width: 6),
Expanded(
child: Text(
lastAppearedStr == 'Actively advertising'
? lastAppearedStr
: 'Last seen: $lastAppearedStr',
style: TextStyle(
fontSize: 11,
fontFamily: 'monospace',
color: colorScheme.onSurface
.withValues(alpha: 0.7),
),
overflow: TextOverflow.ellipsis,
),
),
],
),
const SizedBox(height: 6),
Wrap(
spacing: 4,
runSpacing: 4,
children: [
// Pair status
Container(
padding: const EdgeInsets.symmetric(
horizontal: 8,
vertical: 4,
),
decoration: BoxDecoration(
color: widget.bleDevice.paired == true
? Colors.green.withValues(alpha: 0.2)
: Colors.orange.withValues(alpha: 0.2),
borderRadius: BorderRadius.circular(8),
),
child: Text(
widget.bleDevice.paired == true
? 'Paired'
: 'Unpaired',
style: TextStyle(
fontSize: 10,
fontWeight: FontWeight.w600,
color: widget.bleDevice.paired == true
? Colors.green.shade700
: Colors.orange.shade700,
),
),
),
// Manufacturer data (only in collapsed mode)
if (!widget.isExpanded) ...[
...rawManufacturerData.take(2).map((data) {
final companyName = CompanyIdentifierService
.instance
.getCompanyName(data.companyId);
return Container(
padding: const EdgeInsets.symmetric(
horizontal: 8,
vertical: 4,
),
decoration: BoxDecoration(
color: colorScheme.secondaryContainer,
borderRadius: BorderRadius.circular(8),
),
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,
),
],
],
),
);
}),
],
// Services (only in collapsed mode)
if (!widget.isExpanded) ...[
...widget.bleDevice.services.take(3).map((service) {
return Container(
padding: const EdgeInsets.symmetric(
horizontal: 8,
vertical: 4,
),
decoration: BoxDecoration(
color: colorScheme.tertiaryContainer,
borderRadius: BorderRadius.circular(8),
),
child: Text(
service.length > 12
? '${service.substring(0, 12)}...'
: service,
style: TextStyle(
fontSize: 10,
color: colorScheme.onTertiaryContainer,
fontWeight: FontWeight.w500,
),
),
);
}),
if (widget.bleDevice.services.length > 3)
Text(
'+${widget.bleDevice.services.length - 3} more',
style: TextStyle(
fontSize: 10,
color: colorScheme.onSurface
.withValues(alpha: 0.5),
),
),
],
],
),
],
),
),
],
child: ListTile(
title: Text(
'$name (${bleDevice.rssi})',
),
subtitle: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(bleDevice.deviceId),
Visibility(
visible: manufacturerData != null,
child: Text(manufacturerData.toString()),
),
// Expanded details
if (widget.isExpanded) ...[
const SizedBox(height: 16),
const Divider(),
const SizedBox(height: 12),
// Manufacturer Data (always shown)
Text(
'Manufacturer Data',
style: TextStyle(
fontSize: 12,
fontWeight: FontWeight.bold,
color: colorScheme.onSurface,
),
),
const SizedBox(height: 8),
if (rawManufacturerData.isEmpty)
Container(
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
color: colorScheme.secondaryContainer,
borderRadius: BorderRadius.circular(8),
if (bleDevice.timestampDateTime != null)
Text("Last Seen: ${bleDevice.timestampDateTime}"),
bleDevice.paired == true
? const Text(
"Paired",
style: TextStyle(color: Colors.green),
)
: const Text(
"Not Paired",
style: TextStyle(color: Colors.red),
),
child: Text(
'No manufacturer data',
style: TextStyle(
fontSize: 11,
color: colorScheme.onSecondaryContainer
.withValues(alpha: 0.7),
fontStyle: FontStyle.italic,
),
),
)
else
...rawManufacturerData.map((data) {
return Padding(
padding: const EdgeInsets.only(bottom: 8),
child: Container(
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
color: colorScheme.secondaryContainer,
borderRadius: BorderRadius.circular(8),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Expanded(
child: Column(
crossAxisAlignment:
CrossAxisAlignment.start,
children: [
Row(
children: [
Text(
'Company ID: ',
style: TextStyle(
fontSize: 11,
fontWeight: FontWeight.w600,
color: colorScheme
.onSecondaryContainer,
),
),
Expanded(
child: SelectableText(
data.companyIdRadix16,
style: TextStyle(
fontSize: 11,
fontWeight: FontWeight.w600,
color: colorScheme
.onSecondaryContainer,
fontFamily: 'monospace',
),
),
),
],
),
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(
crossAxisAlignment:
CrossAxisAlignment.start,
children: [
Expanded(
child: SelectableText(
data.payloadRadix16,
style: TextStyle(
fontSize: 10,
color: colorScheme
.onSecondaryContainer
.withValues(alpha: 0.8),
fontFamily: 'monospace',
),
),
),
],
),
],
],
),
),
const SizedBox(width: 8),
IconButton(
icon: Icon(
Icons.copy,
size: 16,
color: colorScheme.onSecondaryContainer,
),
onPressed: () {
final textToCopy = data
.payloadRadix16.isNotEmpty
? 'Company ID: ${data.companyIdRadix16}\nPayload: ${data.payloadRadix16}'
: 'Company ID: ${data.companyIdRadix16}';
Clipboard.setData(
ClipboardData(text: textToCopy),
);
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content:
const Text('Copied to clipboard'),
duration: const Duration(seconds: 1),
behavior: SnackBarBehavior.floating,
),
);
},
tooltip: 'Copy',
padding: EdgeInsets.zero,
constraints: const BoxConstraints(
minWidth: 24,
minHeight: 24,
),
visualDensity: VisualDensity.compact,
),
],
),
],
),
),
);
}),
const SizedBox(height: 12),
if (widget.bleDevice.services.isNotEmpty) ...[
Text(
'Advertised Services',
style: TextStyle(
fontSize: 12,
fontWeight: FontWeight.bold,
color: colorScheme.onSurface,
),
),
const SizedBox(height: 8),
Wrap(
spacing: 4,
runSpacing: 4,
children: widget.bleDevice.services.map((service) {
return Container(
padding: const EdgeInsets.symmetric(
horizontal: 8,
vertical: 4,
),
decoration: BoxDecoration(
color: colorScheme.tertiaryContainer,
borderRadius: BorderRadius.circular(8),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
SelectableText(
service,
style: TextStyle(
fontSize: 10,
color: colorScheme.onTertiaryContainer,
fontWeight: FontWeight.w500,
fontFamily: 'monospace',
),
),
const SizedBox(width: 6),
InkWell(
onTap: () {
Clipboard.setData(ClipboardData(text: service));
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: const Text('Copied to clipboard'),
duration: const Duration(seconds: 1),
behavior: SnackBarBehavior.floating,
),
);
},
child: Icon(
Icons.copy,
size: 14,
color: colorScheme.onTertiaryContainer
.withValues(alpha: 0.7),
),
),
],
),
);
}).toList(),
),
],
// Service Data (always shown, last)
Text(
'Service Data',
style: TextStyle(
fontSize: 12,
fontWeight: FontWeight.bold,
color: colorScheme.onSurface,
),
),
const SizedBox(height: 8),
if (widget.bleDevice.serviceData.isEmpty)
Container(
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
color: colorScheme.tertiaryContainer,
borderRadius: BorderRadius.circular(8),
),
child: Text(
'No service data',
style: TextStyle(
fontSize: 11,
color: colorScheme.onTertiaryContainer
.withValues(alpha: 0.7),
fontStyle: FontStyle.italic,
),
),
)
else
...widget.bleDevice.serviceData.entries.map((entry) {
final uuid = entry.key;
final bytes = entry.value;
final hex = bytes.isEmpty
? ''
: '0x${bytes.map((b) => b.toRadixString(16).padLeft(2, '0')).join('').toUpperCase()}';
return Padding(
padding: const EdgeInsets.only(bottom: 8),
child: Container(
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
color: colorScheme.tertiaryContainer,
borderRadius: BorderRadius.circular(8),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Expanded(
child: Column(
crossAxisAlignment:
CrossAxisAlignment.start,
children: [
Row(
children: [
Text(
'Service UUID: ',
style: TextStyle(
fontSize: 11,
fontWeight: FontWeight.w600,
color: colorScheme
.onTertiaryContainer,
),
),
Expanded(
child: SelectableText(
uuid,
style: TextStyle(
fontSize: 11,
fontWeight: FontWeight.w600,
color: colorScheme
.onTertiaryContainer,
fontFamily: 'monospace',
),
),
),
],
),
if (hex.isNotEmpty) ...[
const SizedBox(height: 4),
SelectableText(
hex,
style: TextStyle(
fontSize: 10,
color: colorScheme
.onTertiaryContainer
.withValues(alpha: 0.8),
fontFamily: 'monospace',
),
),
],
],
),
),
const SizedBox(width: 8),
IconButton(
icon: Icon(
Icons.copy,
size: 16,
color: colorScheme.onTertiaryContainer,
),
onPressed: () {
final textToCopy = hex.isNotEmpty
? 'Service UUID: $uuid\nData: $hex'
: 'Service UUID: $uuid';
Clipboard.setData(
ClipboardData(text: textToCopy),
);
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content:
const Text('Copied to clipboard'),
duration: const Duration(seconds: 1),
behavior: SnackBarBehavior.floating,
),
);
},
tooltip: 'Copy',
padding: EdgeInsets.zero,
constraints: const BoxConstraints(
minWidth: 24,
minHeight: 24,
),
visualDensity: VisualDensity.compact,
),
],
),
],
),
),
);
}),
],
],
),
trailing: const Icon(Icons.arrow_forward_ios),
onTap: onTap,
),
),
),
);
}
}
+12 -9
View File
@@ -1,14 +1,17 @@
import 'package:flutter/material.dart';
import 'package:universal_ble_example/data/scan_controller.dart';
import 'package:universal_ble_example/universal_ble_app.dart';
import 'package:universal_ble/universal_ble.dart';
import 'package:universal_ble_example/home/home.dart';
void main() async {
WidgetsFlutterBinding.ensureInitialized();
bool hasPermission = await initializeApp();
final scanController = ScanController();
await scanController.syncState();
runApp(UniversalBleApp(
hasPermission: hasPermission,
scanController: scanController,
));
await UniversalBle.setLogLevel(BleLogLevel.verbose);
runApp(
MaterialApp(
title: 'Universal BLE',
debugShowCheckedModeBanner: false,
darkTheme: ThemeData.dark(),
themeMode: ThemeMode.system,
home: const MyApp(),
),
);
}
-99
View File
@@ -1,99 +0,0 @@
import 'dart:io';
import 'package:device_preview_screenshot/device_preview_screenshot.dart';
import 'package:flutter/material.dart';
import 'package:path_provider/path_provider.dart';
import 'package:universal_ble_example/data/scan_controller.dart';
import 'package:universal_ble_example/universal_ble_app.dart';
void main() async {
bool hasPermission = await initializeApp();
final scanController = ScanController();
await scanController.syncState();
// On macOS: ~/Library/Containers/com.navideck.universalble/Data/Documents/screenshots
final screenshotsDir = await getScreenshotsDirectory();
debugPrint('Screenshots will be saved to: ${screenshotsDir.path}');
runApp(
DevicePreview(
enabled: true,
builder: (context) => UniversalBleApp(
hasPermission: hasPermission,
scanController: scanController,
locale: DevicePreview.locale(context),
builder: DevicePreview.appBuilder,
),
devices: [
createCustomDevice(
name: 'App Store 1242x2688',
physicalWidth: 1242,
physicalHeight: 2688,
),
createCustomDevice(
name: 'App Store iPad 2752x2064',
physicalWidth: 2752,
physicalHeight: 2064,
deviceType: DeviceType.tablet,
),
],
tools: [
...DevicePreview.defaultTools,
DevicePreviewScreenshot(
onScreenshot: screenshotAsFiles(screenshotsDir),
),
],
),
);
}
/// Gets or creates the screenshots directory in the app's documents directory.
Future<Directory> getScreenshotsDirectory() async {
final appDocumentsDir = await getApplicationDocumentsDirectory();
final screenshotsDir = Directory('${appDocumentsDir.path}/screenshots');
if (!await screenshotsDir.exists()) {
await screenshotsDir.create(recursive: true);
}
return screenshotsDir;
}
/// Creates a custom device with the specified physical pixel dimensions.
DeviceInfo createCustomDevice({
required String name,
required double physicalWidth,
required double physicalHeight,
double pixelRatio = 3.0,
DeviceType deviceType = DeviceType.phone,
}) {
final logicalWidth = physicalWidth / pixelRatio;
final logicalHeight = physicalHeight / pixelRatio;
return DeviceInfo(
identifier: DeviceIdentifier(
TargetPlatform.iOS,
deviceType,
name,
),
name: name,
pixelRatio: pixelRatio,
safeAreas: EdgeInsets.zero,
rotatedSafeAreas: EdgeInsets.zero,
screenPath: Path()
..addRect(Rect.fromLTWH(0.0, 0.0, logicalWidth, logicalHeight)),
frameSize: Size(logicalWidth, logicalHeight),
screenSize: Size(logicalWidth, logicalHeight),
framePainter: const _EmptyFramePainter(),
);
}
class _EmptyFramePainter extends CustomPainter {
const _EmptyFramePainter();
@override
void paint(Canvas canvas, Size size) {}
@override
bool shouldRepaint(covariant CustomPainter oldDelegate) => false;
}
File diff suppressed because it is too large Load Diff
@@ -1,106 +0,0 @@
import 'package:flutter/material.dart';
/// Shared header row for result-style cards (e.g. Logs, Received advertisements).
/// Layout: [icon] [title] [copy?] [Spacer] [count?] [clear?]
/// Copy, count badge, and clear button are shown only when [count] > 0.
class ResultHeaderBar extends StatelessWidget {
const ResultHeaderBar({
super.key,
required this.icon,
required this.title,
required this.count,
this.onCopy,
this.copyTooltip = 'Copy',
this.onClear,
this.clearTooltip = 'Clear all',
this.titleFontSize = 16,
});
final IconData icon;
final String title;
final int count;
final VoidCallback? onCopy;
final String copyTooltip;
final VoidCallback? onClear;
final String clearTooltip;
final double titleFontSize;
bool get _hasItems => count > 0;
@override
Widget build(BuildContext context) {
final colorScheme = Theme.of(context).colorScheme;
return Row(
children: [
Icon(
icon,
color: colorScheme.primary,
size: 20,
),
const SizedBox(width: 8),
Text(
title,
style: TextStyle(
fontWeight: FontWeight.bold,
fontSize: titleFontSize,
color: colorScheme.onSurface,
),
),
if (_hasItems && onCopy != null) ...[
const SizedBox(width: 8),
IconButton(
icon: Icon(
Icons.copy,
color: colorScheme.primary,
size: 20,
),
onPressed: onCopy,
tooltip: copyTooltip,
padding: EdgeInsets.zero,
constraints: const BoxConstraints(
minWidth: 32,
minHeight: 32,
),
),
],
const Spacer(),
if (_hasItems)
Container(
padding: const EdgeInsets.symmetric(
horizontal: 8,
vertical: 4,
),
decoration: BoxDecoration(
color: colorScheme.primaryContainer,
borderRadius: BorderRadius.circular(8),
),
child: Text(
'$count',
style: TextStyle(
color: colorScheme.onPrimaryContainer,
fontSize: 12,
fontWeight: FontWeight.w600,
),
),
),
if (_hasItems && onClear != null) ...[
const SizedBox(width: 8),
IconButton(
icon: Icon(
Icons.close,
color: colorScheme.error,
size: 20,
),
onPressed: onClear,
tooltip: clearTooltip,
padding: EdgeInsets.zero,
constraints: const BoxConstraints(
minWidth: 32,
minHeight: 32,
),
),
],
],
);
}
}
@@ -1,155 +1,50 @@
import 'package:flutter/material.dart';
import 'package:universal_ble_example/peripheral_details/widgets/result_header_bar.dart';
class ResultWidget extends StatelessWidget {
final List<String> results;
final bool scrollable;
final ScrollController scrollController;
final void Function(int? index) onClearTap;
final void Function()? onCopyTap;
const ResultWidget({
required this.results,
required this.onClearTap,
this.onCopyTap,
this.scrollable = false,
required this.scrollController,
super.key,
});
@override
Widget build(BuildContext context) {
final colorScheme = Theme.of(context).colorScheme;
return Padding(
padding: const EdgeInsets.all(16.0),
child: Card(
elevation: 2,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: scrollable ? MainAxisSize.max : MainAxisSize.min,
children: [
_buildHeader(colorScheme),
if (results.isEmpty)
_buildEmptyState(colorScheme)
else
_buildLogsList(colorScheme),
],
),
),
);
}
Widget _buildHeader(ColorScheme colorScheme) {
return Container(
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: colorScheme.surfaceContainerHighest,
borderRadius: const BorderRadius.only(
topLeft: Radius.circular(12),
topRight: Radius.circular(12),
),
),
child: ResultHeaderBar(
icon: Icons.history,
title: 'Logs',
count: results.length,
onCopy: onCopyTap,
copyTooltip: 'Copy all logs',
onClear: () => onClearTap(null),
clearTooltip: 'Clear all logs',
),
);
}
Widget _buildEmptyState(ColorScheme colorScheme) {
final emptyStateContent = Padding(
padding: const EdgeInsets.all(32.0),
child: Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(
Icons.history_outlined,
size: 48,
color: colorScheme.onSurface.withValues(alpha: 0.3),
),
const SizedBox(height: 16),
Text(
'No logs yet',
style: TextStyle(
color: colorScheme.onSurface.withValues(alpha: 0.6),
),
),
],
),
),
);
return scrollable ? Expanded(child: emptyStateContent) : emptyStateContent;
}
Widget _buildLogsList(ColorScheme colorScheme) {
final listView = ListView.separated(
physics: const AlwaysScrollableScrollPhysics(),
controller: scrollController,
itemCount: results.length,
itemBuilder: (context, index) => _buildLogItem(colorScheme, index),
separatorBuilder: (_, __) => Divider(
height: 1,
color: colorScheme.outline.withValues(alpha: 0.2),
),
);
if (scrollable) {
return Expanded(child: listView);
} else {
return Container(
constraints: const BoxConstraints(maxHeight: 300),
child: listView,
);
}
}
Widget _buildLogItem(ColorScheme colorScheme, int index) {
var reversedIndex = results.length - index - 1;
final log = results[reversedIndex];
return Container(
padding: const EdgeInsets.symmetric(
horizontal: 16,
vertical: 12,
),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Expanded(
child: SelectableText(
log,
style: TextStyle(
fontSize: 12,
fontFamily: 'monospace',
color: colorScheme.onSurface,
),
return Column(
children: [
if (results.isNotEmpty)
Padding(
padding: const EdgeInsets.all(8.0),
child: ListTile(
tileColor: Theme.of(context).secondaryHeaderColor,
title: const Text("Logs"),
onTap: () {
onClearTap(null);
},
trailing: const Icon(Icons.clear),
),
),
const SizedBox(width: 8),
IconButton(
icon: Icon(
Icons.close,
size: 16,
color: colorScheme.onSurface.withValues(alpha: 0.4),
),
onPressed: () => onClearTap(reversedIndex),
padding: EdgeInsets.zero,
constraints: const BoxConstraints(
minWidth: 24,
minHeight: 24,
),
),
],
),
ListView.separated(
shrinkWrap: !scrollable,
physics: scrollable ? null : const NeverScrollableScrollPhysics(),
itemCount: results.length,
reverse: true,
itemBuilder: (BuildContext context, int index) {
return InkWell(
onTap: () => onClearTap(index),
child: Padding(
padding:
const EdgeInsets.symmetric(horizontal: 11, vertical: 2),
child: Text(results[index]),
),
);
},
separatorBuilder: (_, __) => const Divider(),
),
],
);
}
}
@@ -1,448 +1,78 @@
import 'package:expandable/expandable.dart';
import 'package:flutter/material.dart';
import 'package:universal_ble/universal_ble.dart';
import 'package:universal_ble_example/data/utils.dart';
class ServicesListWidget extends StatefulWidget {
class ServicesListWidget extends StatelessWidget {
final List<BleService> discoveredServices;
final bool scrollable;
final void Function(BleService service, BleCharacteristic characteristic)?
onTap;
final BleService? selectedService;
final BleCharacteristic? selectedCharacteristic;
final Set<String>? favoriteServices;
final Map<String, bool>? subscribedCharacteristics;
final void Function(String serviceUuid)? onFavoriteToggle;
final Set<CharacteristicProperty>? propertyFilters;
final bool isDiscoveringServices;
final void Function(
BleService service,
BleCharacteristic characteristic,
)? onTap;
const ServicesListWidget({
super.key,
required this.discoveredServices,
this.onTap,
this.scrollable = false,
this.selectedService,
this.selectedCharacteristic,
this.favoriteServices,
this.subscribedCharacteristics,
this.onFavoriteToggle,
this.propertyFilters,
this.isDiscoveringServices = false,
});
@override
State<ServicesListWidget> createState() => ServicesListWidgetState();
}
class ServicesListWidgetState extends State<ServicesListWidget> {
final Map<String, ExpansibleController> _expandableControllers = {};
ScrollController? _scrollController;
final Map<String, GlobalKey> _characteristicKeys = {};
@override
void initState() {
super.initState();
if (widget.scrollable) {
_scrollController = ScrollController();
}
_initializeControllers();
// Scroll to selected characteristic after the first frame
WidgetsBinding.instance.addPostFrameCallback((_) {
_scrollToSelectedCharacteristic();
});
}
@override
void didUpdateWidget(ServicesListWidget oldWidget) {
super.didUpdateWidget(oldWidget);
// Create or dispose scroll controller if scrollable state changed
if (oldWidget.scrollable != widget.scrollable) {
if (widget.scrollable && _scrollController == null) {
_scrollController = ScrollController();
} else if (!widget.scrollable && _scrollController != null) {
_scrollController!.dispose();
_scrollController = null;
}
}
// Update controllers if services or selection changed
if (oldWidget.discoveredServices != widget.discoveredServices ||
oldWidget.selectedCharacteristic != widget.selectedCharacteristic) {
_initializeControllers();
WidgetsBinding.instance.addPostFrameCallback((_) {
_scrollToSelectedCharacteristic();
});
}
}
@override
void dispose() {
for (var controller in _expandableControllers.values) {
controller.dispose();
}
_scrollController?.dispose();
super.dispose();
}
void _initializeControllers() {
// Dispose old controllers
for (var controller in _expandableControllers.values) {
controller.dispose();
}
_expandableControllers.clear();
_characteristicKeys.clear();
// Create controllers for each service
final sortedServices = _getSortedServices();
for (var service in sortedServices) {
final controller = ExpansibleController();
// Expand if this service contains the selected characteristic
if (widget.selectedCharacteristic != null) {
final hasSelectedChar = service.characteristics.any(
(char) => char.uuid == widget.selectedCharacteristic!.uuid,
);
if (hasSelectedChar) {
controller.expand();
}
}
_expandableControllers[service.uuid] = controller;
// Create keys for characteristics
for (var char in service.characteristics) {
_characteristicKeys['${service.uuid}_${char.uuid}'] = GlobalKey();
}
}
}
void _scrollToSelectedCharacteristic() {
if (widget.selectedCharacteristic == null) return;
// Find the key for the selected characteristic
String? selectedKey;
// Prefer an exact match on both service and characteristic UUIDs
if (widget.selectedService != null) {
final exactKey =
'${widget.selectedService!.uuid}_${widget.selectedCharacteristic!.uuid}';
if (_characteristicKeys.containsKey(exactKey)) {
selectedKey = exactKey;
}
}
// Fallback: match by characteristic UUID only if no exact key was found
if (selectedKey == null) {
for (var entry in _characteristicKeys.entries) {
if (entry.key.endsWith('_${widget.selectedCharacteristic!.uuid}')) {
selectedKey = entry.key;
break;
}
}
}
if (selectedKey != null) {
final key = _characteristicKeys[selectedKey];
if (key?.currentContext != null) {
// Wait a bit for the expansion animation to complete
Future.delayed(const Duration(milliseconds: 100), () {
if (mounted && key?.currentContext != null) {
Scrollable.ensureVisible(
key!.currentContext!,
duration: const Duration(milliseconds: 300),
curve: Curves.easeInOut,
);
}
});
}
}
}
List<BleService> _getSortedServices() {
return sortBleServices(
widget.discoveredServices,
favoriteServices: widget.favoriteServices,
);
}
/// Returns a list of all filtered characteristics with their parent services
List<({BleService service, BleCharacteristic characteristic})>
getFilteredCharacteristics() {
return getFilteredBleCharacteristics(
widget.discoveredServices,
favoriteServices: widget.favoriteServices,
propertyFilters: widget.propertyFilters,
);
}
@override
Widget build(BuildContext context) {
final colorScheme = Theme.of(context).colorScheme;
final favoriteStarColor = Colors.amber;
final subscribedNotificationIconColor = Colors.green;
final selectedColor = colorScheme.primary;
final selectedCharacteristicBackgroundColor =
colorScheme.primaryContainer.withValues(alpha: 0.5);
final sortedServices = _getSortedServices();
// Show loading indicator when discovering services and list is empty
if (widget.isDiscoveringServices && sortedServices.isEmpty) {
return Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
CircularProgressIndicator(
color: colorScheme.primary,
),
const SizedBox(height: 16),
Text(
'Discovering services...',
style: TextStyle(
color: colorScheme.onSurface.withValues(alpha: 0.6),
fontSize: 14,
),
),
],
),
);
}
// Show loading overlay when re-discovering services (list has items)
if (widget.isDiscoveringServices && sortedServices.isNotEmpty) {
return Stack(
children: [
_buildServicesListView(
sortedServices,
colorScheme,
favoriteStarColor,
subscribedNotificationIconColor,
selectedColor,
selectedCharacteristicBackgroundColor),
Positioned.fill(
child: Container(
color: colorScheme.surface.withValues(alpha: 0.7),
child: Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
CircularProgressIndicator(
color: colorScheme.primary,
),
const SizedBox(height: 16),
Text(
'Discovering services...',
style: TextStyle(
color: colorScheme.onSurface.withValues(alpha: 0.8),
fontSize: 14,
fontWeight: FontWeight.w500,
),
),
],
),
),
),
),
],
);
}
// Normal list view when not loading
return _buildServicesListView(
sortedServices,
colorScheme,
favoriteStarColor,
subscribedNotificationIconColor,
selectedColor,
selectedCharacteristicBackgroundColor);
}
Widget _buildServicesListView(
List<BleService> sortedServices,
ColorScheme colorScheme,
Color favoriteStarColor,
Color subscribedNotificationIconColor,
Color selectedColor,
Color selectedCharacteristicBackgroundColor,
) {
return ListView.builder(
controller: _scrollController,
shrinkWrap: !widget.scrollable,
physics: widget.scrollable ? null : const NeverScrollableScrollPhysics(),
itemCount: sortedServices.length,
shrinkWrap: !scrollable,
physics: scrollable ? null : const NeverScrollableScrollPhysics(),
itemCount: discoveredServices.length,
itemBuilder: (BuildContext context, int index) {
final service = sortedServices[index];
final isFavorite =
widget.favoriteServices?.contains(service.uuid) ?? false;
final isSelected = widget.selectedService?.uuid == service.uuid;
final controller =
_expandableControllers[service.uuid] ?? ExpansibleController();
return Padding(
padding: const EdgeInsets.symmetric(horizontal: 8.0, vertical: 4.0),
padding: const EdgeInsets.all(8.0),
child: Card(
elevation: isSelected ? 2 : 1,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12),
side: isSelected
? BorderSide(color: selectedColor, width: 2)
: BorderSide.none,
),
child: Theme(
data: Theme.of(context).copyWith(
splashFactory: NoSplash.splashFactory,
highlightColor: Colors.transparent,
hoverColor: Colors.transparent,
dividerColor: Colors.transparent,
),
child: ExpansionTile(
controller: controller,
tilePadding:
const EdgeInsets.symmetric(horizontal: 12.0, vertical: 0),
shape: const Border(),
collapsedShape: const Border(),
title: Row(
child: ExpandablePanel(
header: Padding(
padding: const EdgeInsets.all(8.0),
child: Row(
children: [
Expanded(
child: Text(
service.uuid,
style: TextStyle(
fontSize: 13,
fontFamily: 'monospace',
color: colorScheme.onSurface,
fontWeight:
isSelected ? FontWeight.bold : FontWeight.w500,
),
),
),
if (widget.onFavoriteToggle != null) ...[
const SizedBox(width: 8),
IconButton(
icon: Icon(
isFavorite ? Icons.star : Icons.star_border,
color: isFavorite
? favoriteStarColor
: colorScheme.onSurface.withValues(alpha: 0.4),
),
onPressed: () => widget.onFavoriteToggle!(service.uuid),
iconSize: 20,
padding: EdgeInsets.zero,
constraints: const BoxConstraints(
minWidth: 32,
minHeight: 32,
),
splashColor: Colors.transparent,
highlightColor: Colors.transparent,
hoverColor: Colors.transparent,
),
],
const Icon(Icons.arrow_forward_ios),
Expanded(child: Text(discoveredServices[index].uuid)),
],
),
childrenPadding: const EdgeInsets.only(bottom: 8.0),
children: service.characteristics.where((e) {
// Filter by properties if filters are selected
if (widget.propertyFilters != null &&
widget.propertyFilters!.isNotEmpty) {
return e.properties
.any((prop) => widget.propertyFilters!.contains(prop));
}
return true;
}).map((e) {
final isCharSelected =
widget.selectedCharacteristic?.uuid == e.uuid;
final isSubscribed =
widget.subscribedCharacteristics?[e.uuid] ?? false;
final charKey =
_characteristicKeys['${service.uuid}_${e.uuid}'];
return Padding(
key: charKey,
padding: const EdgeInsets.symmetric(
horizontal: 8.0,
vertical: 4.0,
),
child: GestureDetector(
onTap: () {
widget.onTap?.call(service, e);
},
behavior: HitTestBehavior.opaque,
child: Container(
decoration: BoxDecoration(
color: isCharSelected
? selectedCharacteristicBackgroundColor
: colorScheme.surfaceContainerHighest
.withValues(alpha: 0.3),
borderRadius: BorderRadius.circular(8),
border: isCharSelected
? Border.all(color: selectedColor, width: 1.5)
: null,
),
child: Padding(
padding: const EdgeInsets.all(12.0),
),
collapsed: const SizedBox(),
expanded: Column(
children: discoveredServices[index]
.characteristics
.map((e) => Padding(
padding: const EdgeInsets.all(8.0),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Icon(
Icons.arrow_right_outlined,
size: 16,
color: isCharSelected
? selectedColor
: colorScheme.onSurface
.withValues(alpha: 0.6),
),
const SizedBox(width: 8),
if (isSubscribed) ...[
Icon(
Icons.notifications_active,
color: subscribedNotificationIconColor,
size: 16,
InkWell(
onTap: () {
onTap?.call(discoveredServices[index], e);
},
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
const Icon(Icons.arrow_right_outlined),
Expanded(child: Text(e.uuid)),
],
),
const SizedBox(width: 4),
Text(
"Properties: ${e.properties.map((e) => e.name)}",
),
if (e.descriptors.isNotEmpty)
Text(
"Descriptors: ${e.descriptors.map((e) => e.uuid).join(', ')}",
)
],
Expanded(
child: Text(
e.uuid,
style: TextStyle(
fontSize: 12,
fontFamily: 'monospace',
fontWeight: isCharSelected
? FontWeight.bold
: FontWeight.w500,
color: isCharSelected
? selectedColor
: colorScheme.onSurface,
),
),
),
],
),
const SizedBox(height: 8),
Wrap(
spacing: 6,
runSpacing: 6,
children: e.properties.map((prop) {
return Container(
padding: const EdgeInsets.symmetric(
horizontal: 6,
vertical: 2,
),
decoration: BoxDecoration(
color: colorScheme.secondaryContainer,
borderRadius: BorderRadius.circular(4),
),
child: Text(
prop.name,
style: TextStyle(
fontSize: 10,
color: colorScheme.onSecondaryContainer,
fontWeight: FontWeight.w600,
),
),
);
}).toList(),
),
),
],
),
),
),
),
);
}).toList(),
))
.toList(),
),
),
),
@@ -1,394 +0,0 @@
import 'package:flutter/material.dart';
import 'package:universal_ble/universal_ble.dart';
import 'package:universal_ble_example/data/utils.dart';
import 'services_list_widget.dart';
class ServicesSideWidget extends StatefulWidget {
final List<BleService> discoveredServices;
final Function(Set<CharacteristicProperty>? selectedProperties,
GlobalKey<ServicesListWidgetState>? listKey, bool isDiscoveringServices)
serviceListBuilder;
final VoidCallback? onCopyServices;
final BleService? selectedService;
final BleCharacteristic? selectedCharacteristic;
final Function(BleService service, BleCharacteristic characteristic)?
onCharacteristicSelected;
final Function(Set<CharacteristicProperty>? propertyFilters)?
onPropertyFiltersChanged;
final Set<CharacteristicProperty>? initialPropertyFilters;
final bool isDiscoveringServices;
const ServicesSideWidget({
super.key,
required this.discoveredServices,
required this.serviceListBuilder,
this.onCopyServices,
this.selectedService,
this.selectedCharacteristic,
this.onCharacteristicSelected,
this.onPropertyFiltersChanged,
this.initialPropertyFilters,
this.isDiscoveringServices = false,
});
@override
State<ServicesSideWidget> createState() => _ServicesSideWidgetState();
}
class _ServicesSideWidgetState extends State<ServicesSideWidget> {
late Set<CharacteristicProperty>? _selectedProperties;
bool _showFilters = false;
final GlobalKey<ServicesListWidgetState> _servicesListKey = GlobalKey();
@override
void initState() {
super.initState();
_selectedProperties = widget.initialPropertyFilters != null
? Set<CharacteristicProperty>.from(widget.initialPropertyFilters!)
: null;
}
@override
void didUpdateWidget(ServicesSideWidget oldWidget) {
super.didUpdateWidget(oldWidget);
// Update filters if initial filters changed from outside
final newFilters = widget.initialPropertyFilters;
final oldFilters = oldWidget.initialPropertyFilters;
if (newFilters != oldFilters) {
setState(() {
_selectedProperties = newFilters != null
? Set<CharacteristicProperty>.from(newFilters)
: null;
});
}
}
void _togglePropertyFilter(CharacteristicProperty property) {
setState(() {
_selectedProperties ??= <CharacteristicProperty>{};
if (_selectedProperties!.contains(property)) {
_selectedProperties!.remove(property);
if (_selectedProperties!.isEmpty) {
_selectedProperties = null;
}
} else {
_selectedProperties!.add(property);
}
});
widget.onPropertyFiltersChanged?.call(_selectedProperties);
}
void _clearFilters() {
setState(() {
_selectedProperties = null;
});
widget.onPropertyFiltersChanged?.call(null);
}
void _navigateToAdjacent(bool forward) {
final listState = _servicesListKey.currentState;
if (listState == null || widget.selectedCharacteristic == null) return;
final filtered = listState.getFilteredCharacteristics();
final result = navigateToAdjacentCharacteristic(
filtered,
widget.selectedCharacteristic!.uuid,
forward,
);
if (result != null) {
widget.onCharacteristicSelected?.call(
result.service,
result.characteristic,
);
}
}
void _navigateToPrevious() {
_navigateToAdjacent(false);
}
void _navigateToNext() {
_navigateToAdjacent(true);
}
bool _canNavigate() {
final listState = _servicesListKey.currentState;
if (listState == null) return false;
final filtered = listState.getFilteredCharacteristics();
return filtered.length > 1;
}
@override
Widget build(BuildContext context) {
final colorScheme = Theme.of(context).colorScheme;
final hasActiveFilters =
_selectedProperties != null && _selectedProperties!.isNotEmpty;
return Container(
decoration: BoxDecoration(
color: colorScheme.surfaceContainerHighest,
border: Border(
right: BorderSide(
color: colorScheme.outline.withValues(alpha: 0.2),
width: 1,
),
),
),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Container(
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: colorScheme.surface,
border: Border(
bottom: BorderSide(
color: colorScheme.outline.withValues(alpha: 0.2),
width: 1,
),
),
),
child: Column(
children: [
Row(
children: [
Icon(
Icons.apps,
color: colorScheme.primary,
size: 20,
),
const SizedBox(width: 8),
Text(
'Services',
style: TextStyle(
fontWeight: FontWeight.bold,
fontSize: 16,
color: colorScheme.onSurface,
),
),
const Spacer(),
// Navigation buttons
if (_canNavigate()) ...[
IconButton(
onPressed: _navigateToPrevious,
icon: const Icon(Icons.arrow_back_ios),
iconSize: 18,
tooltip: 'Previous Characteristic',
padding: EdgeInsets.zero,
constraints: const BoxConstraints(
minWidth: 32,
minHeight: 32,
),
color: colorScheme.primary,
),
IconButton(
onPressed: _navigateToNext,
icon: const Icon(Icons.arrow_forward_ios),
iconSize: 18,
tooltip: 'Next Characteristic',
padding: EdgeInsets.zero,
constraints: const BoxConstraints(
minWidth: 32,
minHeight: 32,
),
color: colorScheme.primary,
),
const SizedBox(width: 4),
],
IconButton(
onPressed: () {
setState(() {
_showFilters = !_showFilters;
});
},
icon: Icon(
Icons.filter_list,
color: hasActiveFilters
? colorScheme.primary
: colorScheme.onSurface,
),
iconSize: 18,
tooltip: 'Filter by Properties',
padding: EdgeInsets.zero,
constraints: const BoxConstraints(
minWidth: 32,
minHeight: 32,
),
),
if (widget.onCopyServices != null)
Padding(
padding: const EdgeInsets.only(right: 8),
child: IconButton(
onPressed: widget.onCopyServices,
icon: const Icon(Icons.copy),
iconSize: 18,
tooltip: 'Copy Services',
padding: EdgeInsets.zero,
constraints: const BoxConstraints(
minWidth: 32,
minHeight: 32,
),
color: colorScheme.onSurface,
),
),
Container(
padding: const EdgeInsets.symmetric(
horizontal: 8,
vertical: 4,
),
decoration: BoxDecoration(
color: colorScheme.primaryContainer,
borderRadius: BorderRadius.circular(8),
),
child: Text(
'${widget.discoveredServices.length}',
style: TextStyle(
color: colorScheme.onPrimaryContainer,
fontSize: 12,
fontWeight: FontWeight.w600,
),
),
),
],
),
if (_showFilters) ...[
const SizedBox(height: 12),
Container(
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
color: colorScheme.surfaceContainerHighest,
borderRadius: BorderRadius.circular(8),
border: Border.all(
color: colorScheme.outline.withValues(alpha: 0.2),
),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Text(
'Filter by Properties',
style: TextStyle(
fontSize: 12,
fontWeight: FontWeight.w600,
color: colorScheme.onSurface,
),
),
const Spacer(),
if (hasActiveFilters)
TextButton(
onPressed: _clearFilters,
style: TextButton.styleFrom(
padding: const EdgeInsets.symmetric(
horizontal: 8,
vertical: 4,
),
minimumSize: Size.zero,
tapTargetSize:
MaterialTapTargetSize.shrinkWrap,
),
child: Text(
'Clear',
style: TextStyle(
fontSize: 11,
color: colorScheme.primary,
),
),
),
],
),
const SizedBox(height: 8),
Wrap(
spacing: 6,
runSpacing: 6,
children:
CharacteristicProperty.values.map((property) {
final isSelected =
_selectedProperties?.contains(property) ??
false;
return FilterChip(
label: Text(
property.name,
style: TextStyle(
fontSize: 11,
fontWeight: isSelected
? FontWeight.w600
: FontWeight.normal,
),
),
selected: isSelected,
onSelected: (_) =>
_togglePropertyFilter(property),
selectedColor: colorScheme.primaryContainer,
checkmarkColor: colorScheme.onPrimaryContainer,
labelStyle: TextStyle(
color: isSelected
? colorScheme.onPrimaryContainer
: colorScheme.onSurface,
),
side: BorderSide(
color: isSelected
? colorScheme.primary
: colorScheme.outline
.withValues(alpha: 0.3),
width: isSelected ? 1.5 : 1,
),
);
}).toList(),
),
],
),
),
],
],
),
),
Expanded(
child: widget.discoveredServices.isEmpty
? Center(
child: widget.isDiscoveringServices
? Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
CircularProgressIndicator(
color: colorScheme.primary,
),
const SizedBox(height: 16),
Text(
'Discovering services...',
style: TextStyle(
color: colorScheme.onSurface
.withValues(alpha: 0.6),
fontSize: 14,
),
),
],
)
: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(
Icons.apps_outlined,
size: 48,
color: colorScheme.onSurface
.withValues(alpha: 0.3),
),
const SizedBox(height: 16),
Text(
'No Services Discovered',
style: TextStyle(
color: colorScheme.onSurface
.withValues(alpha: 0.6),
),
),
],
),
)
: widget.serviceListBuilder(
_selectedProperties, _servicesListKey, widget.isDiscoveringServices),
),
],
),
);
}
}
-62
View File
@@ -1,62 +0,0 @@
import 'package:flutter/material.dart';
import 'package:universal_ble/universal_ble.dart';
import 'package:universal_ble_example/data/scan_controller.dart';
import 'package:universal_ble_example/data/storage_service.dart';
import 'package:universal_ble_example/home/permission_screen.dart';
import 'package:universal_ble_example/home/scanner_screen.dart';
import 'package:universal_ble_example/widgets/scan_controller_scope.dart';
/// Initializes the app services and checks permissions.
/// Returns whether the app has the required permissions.
Future<bool> initializeApp() async {
WidgetsFlutterBinding.ensureInitialized();
await StorageService.instance.init();
await UniversalBle.setLogLevel(BleLogLevel.verbose);
return await UniversalBle.hasPermissions(
withAndroidFineLocation: false,
);
}
class UniversalBleApp extends StatelessWidget {
final bool hasPermission;
final ScanController scanController;
final Locale? locale;
final Widget Function(BuildContext, Widget?)? builder;
const UniversalBleApp({
super.key,
required this.hasPermission,
required this.scanController,
this.locale,
this.builder,
});
@override
Widget build(BuildContext context) {
return ScanControllerScope(
controller: scanController,
child: MaterialApp(
title: 'Universal BLE',
debugShowCheckedModeBanner: false,
locale: locale,
builder: builder,
theme: ThemeData(
useMaterial3: true,
colorScheme: ColorScheme.fromSeed(
seedColor: Colors.blue,
brightness: Brightness.light,
),
),
darkTheme: ThemeData(
useMaterial3: true,
colorScheme: ColorScheme.fromSeed(
seedColor: Colors.blue,
brightness: Brightness.dark,
),
),
themeMode: ThemeMode.system,
home: hasPermission ? ScannerScreen() : const PermissionScreen(),
),
);
}
}
@@ -1,76 +0,0 @@
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,124 +0,0 @@
import 'package:flutter/material.dart';
/// Wraps [child] and briefly shows an outline when [trigger] changes.
/// Draws only a stroked border that fades out smoothly; does not block interaction.
class FlashOnUpdateWidget extends StatefulWidget {
const FlashOnUpdateWidget({
super.key,
required this.trigger,
required this.child,
this.highlightColor,
this.borderRadius = 16,
});
/// When this value changes, the outline animation runs (or restarts).
final Object trigger;
final Widget child;
/// If null, [Theme.colorScheme.primaryContainer] is used for the outline.
final Color? highlightColor;
/// Border radius of the outline to match the child (e.g. Card) corners.
final double borderRadius;
@override
State<FlashOnUpdateWidget> createState() => _FlashOnUpdateWidgetState();
}
class _FlashOnUpdateWidgetState extends State<FlashOnUpdateWidget>
with SingleTickerProviderStateMixin {
late final AnimationController _controller;
late final Animation<double> _animation;
@override
void initState() {
super.initState();
_controller = AnimationController(
duration: const Duration(milliseconds: 450),
vsync: this,
);
_animation = CurvedAnimation(
parent: _controller,
curve: Curves.easeOut,
);
}
@override
void didUpdateWidget(FlashOnUpdateWidget oldWidget) {
super.didUpdateWidget(oldWidget);
if (widget.trigger != oldWidget.trigger) {
_controller.forward(from: 0);
}
}
@override
void dispose() {
_controller.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
final colorScheme = Theme.of(context).colorScheme;
final color = widget.highlightColor ?? colorScheme.primaryContainer;
return Stack(
children: [
widget.child,
AnimatedBuilder(
animation: _animation,
builder: (context, _) {
final opacity = 1 - _animation.value;
if (opacity <= 0) return const SizedBox.shrink();
return Positioned.fill(
child: IgnorePointer(
child: CustomPaint(
painter: _OutlinePainter(
color: color,
opacity: opacity,
borderRadius: widget.borderRadius,
),
),
),
);
},
),
],
);
}
}
class _OutlinePainter extends CustomPainter {
_OutlinePainter({
required this.color,
required this.opacity,
required this.borderRadius,
});
static const double _strokeWidth = 2;
final Color color;
final double opacity;
final double borderRadius;
@override
void paint(Canvas canvas, Size size) {
if (opacity <= 0) return;
final paint = Paint()
..color = color.withValues(alpha: opacity)
..style = PaintingStyle.stroke
..strokeWidth = _strokeWidth;
final half = _strokeWidth / 2;
final rect = Rect.fromLTWH(half, half, size.width - _strokeWidth, size.height - _strokeWidth);
final rrect = RRect.fromRectAndRadius(rect, Radius.circular(borderRadius - half));
canvas.drawRRect(rrect, paint);
}
@override
bool shouldRepaint(_OutlinePainter oldDelegate) {
return oldDelegate.opacity != opacity ||
oldDelegate.color != color ||
oldDelegate.borderRadius != borderRadius;
}
}
@@ -1,26 +0,0 @@
import 'package:flutter/material.dart';
import 'package:universal_ble_example/data/scan_controller.dart';
/// Provides [ScanController] to the widget tree so the scan button
/// can be shown on both the scanner and detail screens.
class ScanControllerScope extends InheritedWidget {
const ScanControllerScope({
super.key,
required this.controller,
required super.child,
});
final ScanController controller;
static ScanController of(BuildContext context) {
final scope = context
.dependOnInheritedWidgetOfExactType<ScanControllerScope>();
assert(scope != null, 'No ScanControllerScope found in context');
return scope!.controller;
}
@override
bool updateShouldNotify(ScanControllerScope oldWidget) {
return controller != oldWidget.controller;
}
}
+2 -2
View File
@@ -4,10 +4,10 @@ project(runner LANGUAGES CXX)
# The name of the executable created for the application. Change this to change
# the on-disk name of your application.
set(BINARY_NAME "universal_ble")
set(BINARY_NAME "universal_ble_example")
# The unique GTK application identifier for this application. See:
# https://wiki.gnome.org/HowDoI/ChooseApplicationID
set(APPLICATION_ID "com.navideck.universalble")
set(APPLICATION_ID "com.navideck.universal_ble")
# Explicitly opt in to modern CMake behaviors to avoid warnings with recent
# versions of CMake.
@@ -6,10 +6,6 @@
#include "generated_plugin_registrant.h"
#include <url_launcher_linux/url_launcher_plugin.h>
void fl_register_plugins(FlPluginRegistry* registry) {
g_autoptr(FlPluginRegistrar) url_launcher_linux_registrar =
fl_plugin_registry_get_registrar_for_plugin(registry, "UrlLauncherPlugin");
url_launcher_plugin_register_with_registrar(url_launcher_linux_registrar);
}
@@ -3,7 +3,6 @@
#
list(APPEND FLUTTER_PLUGIN_LIST
url_launcher_linux
)
list(APPEND FLUTTER_FFI_PLUGIN_LIST
+2 -2
View File
@@ -40,11 +40,11 @@ static void my_application_activate(GApplication* application) {
if (use_header_bar) {
GtkHeaderBar* header_bar = GTK_HEADER_BAR(gtk_header_bar_new());
gtk_widget_show(GTK_WIDGET(header_bar));
gtk_header_bar_set_title(header_bar, "Universal BLE");
gtk_header_bar_set_title(header_bar, "universal_ble_example");
gtk_header_bar_set_show_close_button(header_bar, TRUE);
gtk_window_set_titlebar(window, GTK_WIDGET(header_bar));
} else {
gtk_window_set_title(window, "Universal BLE");
gtk_window_set_title(window, "universal_ble_example");
}
gtk_window_set_default_size(window, 1280, 720);
@@ -5,16 +5,8 @@
import FlutterMacOS
import Foundation
import package_info_plus
import path_provider_foundation
import shared_preferences_foundation
import universal_ble
import url_launcher_macos
func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) {
FPPPackageInfoPlusPlugin.register(with: registry.registrar(forPlugin: "FPPPackageInfoPlusPlugin"))
PathProviderPlugin.register(with: registry.registrar(forPlugin: "PathProviderPlugin"))
SharedPreferencesPlugin.register(with: registry.registrar(forPlugin: "SharedPreferencesPlugin"))
UniversalBlePlugin.register(with: registry.registrar(forPlugin: "UniversalBlePlugin"))
UrlLauncherPlugin.register(with: registry.registrar(forPlugin: "UrlLauncherPlugin"))
}
-26
View File
@@ -1,48 +1,22 @@
PODS:
- FlutterMacOS (1.0.0)
- package_info_plus (0.0.1):
- FlutterMacOS
- path_provider_foundation (0.0.1):
- Flutter
- FlutterMacOS
- shared_preferences_foundation (0.0.1):
- Flutter
- FlutterMacOS
- universal_ble (0.0.1):
- Flutter
- FlutterMacOS
- url_launcher_macos (0.0.1):
- FlutterMacOS
DEPENDENCIES:
- FlutterMacOS (from `Flutter/ephemeral`)
- package_info_plus (from `Flutter/ephemeral/.symlinks/plugins/package_info_plus/macos`)
- path_provider_foundation (from `Flutter/ephemeral/.symlinks/plugins/path_provider_foundation/darwin`)
- shared_preferences_foundation (from `Flutter/ephemeral/.symlinks/plugins/shared_preferences_foundation/darwin`)
- universal_ble (from `Flutter/ephemeral/.symlinks/plugins/universal_ble/darwin`)
- url_launcher_macos (from `Flutter/ephemeral/.symlinks/plugins/url_launcher_macos/macos`)
EXTERNAL SOURCES:
FlutterMacOS:
:path: Flutter/ephemeral
package_info_plus:
:path: Flutter/ephemeral/.symlinks/plugins/package_info_plus/macos
path_provider_foundation:
:path: Flutter/ephemeral/.symlinks/plugins/path_provider_foundation/darwin
shared_preferences_foundation:
:path: Flutter/ephemeral/.symlinks/plugins/shared_preferences_foundation/darwin
universal_ble:
:path: Flutter/ephemeral/.symlinks/plugins/universal_ble/darwin
url_launcher_macos:
:path: Flutter/ephemeral/.symlinks/plugins/url_launcher_macos/macos
SPEC CHECKSUMS:
FlutterMacOS: d0db08ddef1a9af05a5ec4b724367152bb0500b1
package_info_plus: f0052d280d17aa382b932f399edf32507174e870
path_provider_foundation: bb55f6dbba17d0dccd6737fe6f7f34fbd0376880
shared_preferences_foundation: 7036424c3d8ec98dfe75ff1667cb0cd531ec82bb
universal_ble: 45519b2aeafe62761e2c6309f8927edb5288b914
url_launcher_macos: f87a979182d112f911de6820aefddaf56ee9fbfd
PODFILE CHECKSUM: 9ebaf0ce3d369aaa26a9ea0e159195ed94724cf3
@@ -68,7 +68,7 @@
331C80D7294CF71000263BE5 /* RunnerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RunnerTests.swift; sourceTree = "<group>"; };
333000ED22D3DE5D00554162 /* Warnings.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = Warnings.xcconfig; sourceTree = "<group>"; };
335BBD1A22A9A15E00E9071D /* GeneratedPluginRegistrant.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = GeneratedPluginRegistrant.swift; sourceTree = "<group>"; };
33CC10ED2044A3C60003C045 /* Universal BLE.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = "Universal BLE.app"; sourceTree = BUILT_PRODUCTS_DIR; };
33CC10ED2044A3C60003C045 /* universal_ble_example.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = universal_ble_example.app; sourceTree = BUILT_PRODUCTS_DIR; };
33CC10F02044A3C60003C045 /* AppDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = "<group>"; };
33CC10F22044A3C60003C045 /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; name = Assets.xcassets; path = Runner/Assets.xcassets; sourceTree = "<group>"; };
33CC10F52044A3C60003C045 /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.xib; name = Base; path = Base.lproj/MainMenu.xib; sourceTree = "<group>"; };
@@ -144,7 +144,7 @@
33CC10EE2044A3C60003C045 /* Products */ = {
isa = PBXGroup;
children = (
33CC10ED2044A3C60003C045 /* Universal BLE.app */,
33CC10ED2044A3C60003C045 /* universal_ble_example.app */,
331C80D5294CF71000263BE5 /* RunnerTests.xctest */,
);
name = Products;
@@ -248,7 +248,7 @@
);
name = Runner;
productName = Runner;
productReference = 33CC10ED2044A3C60003C045 /* Universal BLE.app */;
productReference = 33CC10ED2044A3C60003C045 /* universal_ble_example.app */;
productType = "com.apple.product-type.application";
};
/* End PBXNativeTarget section */
@@ -477,10 +477,10 @@
CURRENT_PROJECT_VERSION = 1;
GENERATE_INFOPLIST_FILE = YES;
MARKETING_VERSION = 1.0;
PRODUCT_BUNDLE_IDENTIFIER = com.navideck.universalble.RunnerTests;
PRODUCT_BUNDLE_IDENTIFIER = com.navideck.universalBleExample.RunnerTests;
PRODUCT_NAME = "$(TARGET_NAME)";
SWIFT_VERSION = 5.0;
TEST_HOST = "$(BUILT_PRODUCTS_DIR)/Universal BLE.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/Universal BLE";
TEST_HOST = "$(BUILT_PRODUCTS_DIR)/universal_ble_example.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/universal_ble_example";
};
name = Debug;
};
@@ -492,10 +492,10 @@
CURRENT_PROJECT_VERSION = 1;
GENERATE_INFOPLIST_FILE = YES;
MARKETING_VERSION = 1.0;
PRODUCT_BUNDLE_IDENTIFIER = com.navideck.universalble.RunnerTests;
PRODUCT_BUNDLE_IDENTIFIER = com.navideck.universalBleExample.RunnerTests;
PRODUCT_NAME = "$(TARGET_NAME)";
SWIFT_VERSION = 5.0;
TEST_HOST = "$(BUILT_PRODUCTS_DIR)/Universal BLE.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/Universal BLE";
TEST_HOST = "$(BUILT_PRODUCTS_DIR)/universal_ble_example.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/universal_ble_example";
};
name = Release;
};
@@ -507,10 +507,10 @@
CURRENT_PROJECT_VERSION = 1;
GENERATE_INFOPLIST_FILE = YES;
MARKETING_VERSION = 1.0;
PRODUCT_BUNDLE_IDENTIFIER = com.navideck.universalble.RunnerTests;
PRODUCT_BUNDLE_IDENTIFIER = com.navideck.universalBleExample.RunnerTests;
PRODUCT_NAME = "$(TARGET_NAME)";
SWIFT_VERSION = 5.0;
TEST_HOST = "$(BUILT_PRODUCTS_DIR)/Universal BLE.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/Universal BLE";
TEST_HOST = "$(BUILT_PRODUCTS_DIR)/universal_ble_example.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/universal_ble_example";
};
name = Profile;
};
@@ -15,7 +15,7 @@
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "33CC10EC2044A3C60003C045"
BuildableName = "Universal BLE.app"
BuildableName = "universal_ble_example.app"
BlueprintName = "Runner"
ReferencedContainer = "container:Runner.xcodeproj">
</BuildableReference>
@@ -31,7 +31,7 @@
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "33CC10EC2044A3C60003C045"
BuildableName = "Universal BLE.app"
BuildableName = "universal_ble_example.app"
BlueprintName = "Runner"
ReferencedContainer = "container:Runner.xcodeproj">
</BuildableReference>
@@ -66,7 +66,7 @@
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "33CC10EC2044A3C60003C045"
BuildableName = "Universal BLE.app"
BuildableName = "universal_ble_example.app"
BlueprintName = "Runner"
ReferencedContainer = "container:Runner.xcodeproj">
</BuildableReference>
@@ -83,7 +83,7 @@
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "33CC10EC2044A3C60003C045"
BuildableName = "Universal BLE.app"
BuildableName = "universal_ble_example.app"
BlueprintName = "Runner"
ReferencedContainer = "container:Runner.xcodeproj">
</BuildableReference>
@@ -1,68 +1,68 @@
{
"info": {
"version": 1,
"author": "xcode"
"images" : [
{
"size" : "16x16",
"idiom" : "mac",
"filename" : "app_icon_16.png",
"scale" : "1x"
},
"images": [
{
"size": "16x16",
"idiom": "mac",
"filename": "app_icon_16.png",
"scale": "1x"
},
{
"size": "16x16",
"idiom": "mac",
"filename": "app_icon_32.png",
"scale": "2x"
},
{
"size": "32x32",
"idiom": "mac",
"filename": "app_icon_32.png",
"scale": "1x"
},
{
"size": "32x32",
"idiom": "mac",
"filename": "app_icon_64.png",
"scale": "2x"
},
{
"size": "128x128",
"idiom": "mac",
"filename": "app_icon_128.png",
"scale": "1x"
},
{
"size": "128x128",
"idiom": "mac",
"filename": "app_icon_256.png",
"scale": "2x"
},
{
"size": "256x256",
"idiom": "mac",
"filename": "app_icon_256.png",
"scale": "1x"
},
{
"size": "256x256",
"idiom": "mac",
"filename": "app_icon_512.png",
"scale": "2x"
},
{
"size": "512x512",
"idiom": "mac",
"filename": "app_icon_512.png",
"scale": "1x"
},
{
"size": "512x512",
"idiom": "mac",
"filename": "app_icon_1024.png",
"scale": "2x"
}
]
}
{
"size" : "16x16",
"idiom" : "mac",
"filename" : "app_icon_32.png",
"scale" : "2x"
},
{
"size" : "32x32",
"idiom" : "mac",
"filename" : "app_icon_32.png",
"scale" : "1x"
},
{
"size" : "32x32",
"idiom" : "mac",
"filename" : "app_icon_64.png",
"scale" : "2x"
},
{
"size" : "128x128",
"idiom" : "mac",
"filename" : "app_icon_128.png",
"scale" : "1x"
},
{
"size" : "128x128",
"idiom" : "mac",
"filename" : "app_icon_256.png",
"scale" : "2x"
},
{
"size" : "256x256",
"idiom" : "mac",
"filename" : "app_icon_256.png",
"scale" : "1x"
},
{
"size" : "256x256",
"idiom" : "mac",
"filename" : "app_icon_512.png",
"scale" : "2x"
},
{
"size" : "512x512",
"idiom" : "mac",
"filename" : "app_icon_512.png",
"scale" : "1x"
},
{
"size" : "512x512",
"idiom" : "mac",
"filename" : "app_icon_1024.png",
"scale" : "2x"
}
],
"info" : {
"version" : 1,
"author" : "xcode"
}
}
Binary file not shown.

Before

Width:  |  Height:  |  Size: 663 KiB

After

Width:  |  Height:  |  Size: 101 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 17 KiB

After

Width:  |  Height:  |  Size: 5.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 578 B

After

Width:  |  Height:  |  Size: 520 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 48 KiB

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.7 KiB

After

Width:  |  Height:  |  Size: 1.0 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 170 KiB

After

Width:  |  Height:  |  Size: 36 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.4 KiB

After

Width:  |  Height:  |  Size: 2.2 KiB

@@ -5,10 +5,10 @@
// 'flutter create' template.
// The application's name. By default this is also the title of the Flutter window.
PRODUCT_NAME = Universal BLE
PRODUCT_NAME = universal_ble_example
// The application's bundle identifier
PRODUCT_BUNDLE_IDENTIFIER = com.navideck.universalble
PRODUCT_BUNDLE_IDENTIFIER = com.navideck.universalBleExample
// The copyright displayed in application information
PRODUCT_COPYRIGHT = Copyright © 2023 com.navideck. All rights reserved.
-32
View File
@@ -1,32 +0,0 @@
package_rename_config:
android:
app_name: "Universal BLE"
package_name: "com.navideck.universalble"
override_old_package: "com.navideck.universal_ble_example"
lang: "kotlin"
ios:
app_name: "Universal BLE"
bundle_name: "Universal BLE"
package_name: "com.navideck.universalble"
linux:
app_name: "Universal BLE"
package_name: "com.navideck.universalble"
exe_name: "universal_ble"
macos:
app_name: "Universal BLE"
package_name: "com.navideck.universalble"
copyright_notice: "Copyright © 2023 com.navideck. All rights reserved."
windows:
app_name: "Universal BLE"
organization: "Navideck"
copyright_notice: "Copyright © 2023 com.navideck. All rights reserved."
exe_name: "universal_ble"
web:
app_name: "Universal BLE"
short_app_name: "Universal BLE"
description: "Universal BLE"
+7 -386
View File
@@ -1,14 +1,6 @@
# Generated by pub
# See https://dart.dev/tools/pub/glossary#lockfile
packages:
archive:
dependency: transitive
description:
name: archive
sha256: "2fde1607386ab523f7a36bb3e7edb43bd58e6edaf2ffb29d8a6d578b297fdbbd"
url: "https://pub.dev"
source: hosted
version: "4.0.7"
args:
dependency: transitive
description:
@@ -49,22 +41,6 @@ packages:
url: "https://pub.dev"
source: hosted
version: "1.4.1"
checked_yaml:
dependency: transitive
description:
name: checked_yaml
sha256: "959525d3162f249993882720d52b7e0c833978df229be20702b33d48d91de70f"
url: "https://pub.dev"
source: hosted
version: "2.0.4"
cli_util:
dependency: transitive
description:
name: cli_util
sha256: ff6785f7e9e3c38ac98b2fb035701789de90154024a75b6cb926445e83197d1c
url: "https://pub.dev"
source: hosted
version: "0.4.2"
clock:
dependency: transitive
description:
@@ -89,22 +65,6 @@ packages:
url: "https://pub.dev"
source: hosted
version: "3.1.2"
crypto:
dependency: transitive
description:
name: crypto
sha256: c8ea0233063ba03258fbcf2ca4d6dadfefe14f02fab57702265467a19f27fadf
url: "https://pub.dev"
source: hosted
version: "3.0.7"
csslib:
dependency: transitive
description:
name: csslib
sha256: "09bad715f418841f976c77db72d5398dc1253c21fb9c0c7f0b0b985860b2d58e"
url: "https://pub.dev"
source: hosted
version: "1.0.2"
cupertino_icons:
dependency: "direct main"
description:
@@ -121,30 +81,14 @@ packages:
url: "https://pub.dev"
source: hosted
version: "0.7.11"
device_frame:
dependency: transitive
description:
name: device_frame
sha256: "7b2ebb2a09d6cc0f086b51bd1412d7be83e0170056a7290349169be41164c86a"
url: "https://pub.dev"
source: hosted
version: "1.4.0"
device_preview:
expandable:
dependency: "direct main"
description:
name: device_preview
sha256: "88aa1cc73ee9a8ec771b309dcbc4000cc66b5d8456b825980997640ab1195bf5"
name: expandable
sha256: "9604d612d4d1146dafa96c6d8eec9c2ff0994658d6d09fed720ab788c7f5afc2"
url: "https://pub.dev"
source: hosted
version: "1.3.1"
device_preview_screenshot:
dependency: "direct main"
description:
name: device_preview_screenshot
sha256: c79f185e155c0ed5d97b4e1afff6df181c2045fe28c7414c95f0b46307c549e6
url: "https://pub.dev"
source: hosted
version: "1.0.0"
version: "5.0.1"
fake_async:
dependency: transitive
description:
@@ -179,15 +123,6 @@ packages:
description: flutter
source: sdk
version: "0.0.0"
flutter_launcher_icons:
dependency: "direct dev"
description:
path: "."
ref: improve-windows-ico-generation
resolved-ref: d623fac15f257f06bfc21ed7afdd0b6f2739ea21
url: "https://github.com/Navideck/flutter_launcher_icons.git"
source: git
version: "0.14.3"
flutter_lints:
dependency: "direct dev"
description:
@@ -196,11 +131,6 @@ packages:
url: "https://pub.dev"
source: hosted
version: "6.0.0"
flutter_localizations:
dependency: transitive
description: flutter
source: sdk
version: "0.0.0"
flutter_test:
dependency: "direct dev"
description: flutter
@@ -214,77 +144,16 @@ packages:
url: "https://pub.dev"
source: hosted
version: "1.1.0"
flutter_web_plugins:
dependency: transitive
description: flutter
source: sdk
version: "0.0.0"
freezed_annotation:
dependency: transitive
description:
name: freezed_annotation
sha256: "7294967ff0a6d98638e7acb774aac3af2550777accd8149c90af5b014e6d44d8"
url: "https://pub.dev"
source: hosted
version: "3.1.0"
fuchsia_remote_debug_protocol:
dependency: transitive
description: flutter
source: sdk
version: "0.0.0"
html:
dependency: transitive
description:
name: html
sha256: "6d1264f2dffa1b1101c25a91dff0dc2daee4c18e87cd8538729773c073dbf602"
url: "https://pub.dev"
source: hosted
version: "0.15.6"
http:
dependency: transitive
description:
name: http
sha256: "87721a4a50b19c7f1d49001e51409bddc46303966ce89a65af4f4e6004896412"
url: "https://pub.dev"
source: hosted
version: "1.6.0"
http_parser:
dependency: transitive
description:
name: http_parser
sha256: "178d74305e7866013777bab2c3d8726205dc5a4dd935297175b19a23a2e66571"
url: "https://pub.dev"
source: hosted
version: "4.1.2"
image:
dependency: transitive
description:
name: image
sha256: "51555e36056541237b15b57afc31a0f53d4f9aefd9bd00873a6dc0090e54e332"
url: "https://pub.dev"
source: hosted
version: "4.6.0"
integration_test:
dependency: "direct dev"
description: flutter
source: sdk
version: "0.0.0"
intl:
dependency: transitive
description:
name: intl
sha256: "3df61194eb431efc39c4ceba583b95633a403f46c9fd341e550ce0bfa50e9aa5"
url: "https://pub.dev"
source: hosted
version: "0.20.2"
json_annotation:
dependency: transitive
description:
name: json_annotation
sha256: "1ce844379ca14835a50d2f019a3099f419082cfdd231cd86a142af94dd5c6bb1"
url: "https://pub.dev"
source: hosted
version: "4.9.0"
leak_tracker:
dependency: transitive
description:
@@ -317,14 +186,6 @@ packages:
url: "https://pub.dev"
source: hosted
version: "6.0.0"
logger:
dependency: transitive
description:
name: logger
sha256: a7967e31b703831a893bbc3c3dd11db08126fe5f369b5c648a36f821979f5be3
url: "https://pub.dev"
source: hosted
version: "2.6.2"
logging:
dependency: transitive
description:
@@ -357,38 +218,6 @@ packages:
url: "https://pub.dev"
source: hosted
version: "1.17.0"
nested:
dependency: transitive
description:
name: nested
sha256: "03bac4c528c64c95c722ec99280375a6f2fc708eec17c7b3f07253b626cd2a20"
url: "https://pub.dev"
source: hosted
version: "1.0.0"
package_info_plus:
dependency: "direct main"
description:
name: package_info_plus
sha256: f69da0d3189a4b4ceaeb1a3defb0f329b3b352517f52bed4290f83d4f06bc08d
url: "https://pub.dev"
source: hosted
version: "9.0.0"
package_info_plus_platform_interface:
dependency: transitive
description:
name: package_info_plus_platform_interface
sha256: "202a487f08836a592a6bd4f901ac69b3a8f146af552bbd14407b6b41e1c3f086"
url: "https://pub.dev"
source: hosted
version: "3.2.1"
package_rename:
dependency: "direct dev"
description:
name: package_rename
sha256: "8e957670ab3c8ab0aa9976d10dfe8575c279e81a48aeeca9e7de43dbdc2c2be0"
url: "https://pub.dev"
source: hosted
version: "1.10.1"
path:
dependency: transitive
description:
@@ -397,54 +226,6 @@ packages:
url: "https://pub.dev"
source: hosted
version: "1.9.1"
path_provider:
dependency: "direct main"
description:
name: path_provider
sha256: "50c5dd5b6e1aaf6fb3a78b33f6aa3afca52bf903a8a5298f53101fdaee55bbcd"
url: "https://pub.dev"
source: hosted
version: "2.1.5"
path_provider_android:
dependency: transitive
description:
name: path_provider_android
sha256: f2c65e21139ce2c3dad46922be8272bb5963516045659e71bb16e151c93b580e
url: "https://pub.dev"
source: hosted
version: "2.2.22"
path_provider_foundation:
dependency: transitive
description:
name: path_provider_foundation
sha256: "6d13aece7b3f5c5a9731eaf553ff9dcbc2eff41087fd2df587fd0fed9a3eb0c4"
url: "https://pub.dev"
source: hosted
version: "2.5.1"
path_provider_linux:
dependency: transitive
description:
name: path_provider_linux
sha256: f7a1fe3a634fe7734c8d3f2766ad746ae2a2884abe22e241a8b301bf5cac3279
url: "https://pub.dev"
source: hosted
version: "2.2.1"
path_provider_platform_interface:
dependency: transitive
description:
name: path_provider_platform_interface
sha256: "88f5779f72ba699763fa3a3b06aa4bf6de76c8e5de842cf6f29e2e06476c2334"
url: "https://pub.dev"
source: hosted
version: "2.1.2"
path_provider_windows:
dependency: transitive
description:
name: path_provider_windows
sha256: bd6f00dbd873bfb70d0761682da2b3a2c2fccc2b9e84c495821639601d81afe7
url: "https://pub.dev"
source: hosted
version: "2.3.0"
petitparser:
dependency: transitive
description:
@@ -469,14 +250,6 @@ packages:
url: "https://pub.dev"
source: hosted
version: "2.1.8"
posix:
dependency: transitive
description:
name: posix
sha256: "6323a5b0fa688b6a010df4905a56b00181479e6d10534cecfecede2aa55add61"
url: "https://pub.dev"
source: hosted
version: "6.0.3"
process:
dependency: transitive
description:
@@ -485,70 +258,6 @@ packages:
url: "https://pub.dev"
source: hosted
version: "5.0.5"
provider:
dependency: transitive
description:
name: provider
sha256: "4e82183fa20e5ca25703ead7e05de9e4cceed1fbd1eadc1ac3cb6f565a09f272"
url: "https://pub.dev"
source: hosted
version: "6.1.5+1"
shared_preferences:
dependency: "direct main"
description:
name: shared_preferences
sha256: "2939ae520c9024cb197fc20dee269cd8cdbf564c8b5746374ec6cacdc5169e64"
url: "https://pub.dev"
source: hosted
version: "2.5.4"
shared_preferences_android:
dependency: transitive
description:
name: shared_preferences_android
sha256: "34266009473bf71d748912da4bf62d439185226c03e01e2d9687bc65bbfcb713"
url: "https://pub.dev"
source: hosted
version: "2.4.15"
shared_preferences_foundation:
dependency: transitive
description:
name: shared_preferences_foundation
sha256: "1c33a907142607c40a7542768ec9badfd16293bac51da3a4482623d15845f88b"
url: "https://pub.dev"
source: hosted
version: "2.5.5"
shared_preferences_linux:
dependency: transitive
description:
name: shared_preferences_linux
sha256: "580abfd40f415611503cae30adf626e6656dfb2f0cee8f465ece7b6defb40f2f"
url: "https://pub.dev"
source: hosted
version: "2.4.1"
shared_preferences_platform_interface:
dependency: transitive
description:
name: shared_preferences_platform_interface
sha256: "57cbf196c486bc2cf1f02b85784932c6094376284b3ad5779d1b1c6c6a816b80"
url: "https://pub.dev"
source: hosted
version: "2.4.1"
shared_preferences_web:
dependency: transitive
description:
name: shared_preferences_web
sha256: c49bd060261c9a3f0ff445892695d6212ff603ef3115edbb448509d407600019
url: "https://pub.dev"
source: hosted
version: "2.4.3"
shared_preferences_windows:
dependency: transitive
description:
name: shared_preferences_windows
sha256: "94ef0f72b2d71bc3e700e025db3710911bd51a71cefb65cc609dd0d9a982e3c1"
url: "https://pub.dev"
source: hosted
version: "2.4.1"
sky_engine:
dependency: transitive
description: flutter
@@ -624,71 +333,7 @@ packages:
path: ".."
relative: true
source: path
version: "1.2.0"
url_launcher:
dependency: "direct main"
description:
name: url_launcher
sha256: f6a7e5c4835bb4e3026a04793a4199ca2d14c739ec378fdfe23fc8075d0439f8
url: "https://pub.dev"
source: hosted
version: "6.3.2"
url_launcher_android:
dependency: transitive
description:
name: url_launcher_android
sha256: "767344bf3063897b5cf0db830e94f904528e6dd50a6dfaf839f0abf509009611"
url: "https://pub.dev"
source: hosted
version: "6.3.28"
url_launcher_ios:
dependency: transitive
description:
name: url_launcher_ios
sha256: cfde38aa257dae62ffe79c87fab20165dfdf6988c1d31b58ebf59b9106062aad
url: "https://pub.dev"
source: hosted
version: "6.3.6"
url_launcher_linux:
dependency: transitive
description:
name: url_launcher_linux
sha256: d5e14138b3bc193a0f63c10a53c94b91d399df0512b1f29b94a043db7482384a
url: "https://pub.dev"
source: hosted
version: "3.2.2"
url_launcher_macos:
dependency: transitive
description:
name: url_launcher_macos
sha256: "368adf46f71ad3c21b8f06614adb38346f193f3a59ba8fe9a2fd74133070ba18"
url: "https://pub.dev"
source: hosted
version: "3.2.5"
url_launcher_platform_interface:
dependency: transitive
description:
name: url_launcher_platform_interface
sha256: "552f8a1e663569be95a8190206a38187b531910283c3e982193e4f2733f01029"
url: "https://pub.dev"
source: hosted
version: "2.3.2"
url_launcher_web:
dependency: transitive
description:
name: url_launcher_web
sha256: "4bd2b7b4dc4d4d0b94e5babfffbca8eac1a126c7f3d6ecbc1a11013faa3abba2"
url: "https://pub.dev"
source: hosted
version: "2.4.1"
url_launcher_windows:
dependency: transitive
description:
name: url_launcher_windows
sha256: "712c70ab1b99744ff066053cbe3e80c73332b38d46e5e945c98689b2e66fc15f"
url: "https://pub.dev"
source: hosted
version: "3.1.5"
version: "1.0.0"
vector_math:
dependency: transitive
description:
@@ -721,22 +366,6 @@ packages:
url: "https://pub.dev"
source: hosted
version: "3.1.0"
win32:
dependency: transitive
description:
name: win32
sha256: d7cb55e04cd34096cd3a79b3330245f54cb96a370a1c27adb3c84b917de8b08e
url: "https://pub.dev"
source: hosted
version: "5.15.0"
xdg_directories:
dependency: transitive
description:
name: xdg_directories
sha256: "7a3f37b05d989967cdddcbb571f1ea834867ae2faa29725fd085180e0883aa15"
url: "https://pub.dev"
source: hosted
version: "1.1.0"
xml:
dependency: transitive
description:
@@ -745,14 +374,6 @@ packages:
url: "https://pub.dev"
source: hosted
version: "6.6.1"
yaml:
dependency: "direct main"
description:
name: yaml
sha256: b9da305ac7c39faa3f030eccd175340f968459dae4af175130b3fc47e40d76ce
url: "https://pub.dev"
source: hosted
version: "3.1.3"
sdks:
dart: ">=3.9.0 <4.0.0"
flutter: ">=3.35.0"
dart: ">=3.9.0-0 <4.0.0"
flutter: ">=3.18.0-18.0.pre.54"
+1 -15
View File
@@ -10,16 +10,10 @@ dependencies:
flutter:
sdk: flutter
convert: ^3.1.1
expandable: ^5.0.1
cupertino_icons: ^1.0.2
shared_preferences: ^2.5.4
package_info_plus: ^9.0.0
url_launcher: ^6.3.2
path_provider: ^2.1.1
yaml: ^3.1.3
universal_ble:
path: ../
device_preview: ^1.3.1
device_preview_screenshot: ^1.0.0
dev_dependencies:
integration_test:
@@ -27,14 +21,6 @@ dev_dependencies:
flutter_test:
sdk: flutter
flutter_lints: ^6.0.0
package_rename: ^1.10.1
flutter_launcher_icons:
git:
url: https://github.com/Navideck/flutter_launcher_icons.git
ref: improve-windows-ico-generation
flutter:
uses-material-design: true
assets:
- assets/icon.png
- assets/company_identifiers.yaml
Binary file not shown.

Before

Width:  |  Height:  |  Size: 578 B

After

Width:  |  Height:  |  Size: 917 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 30 KiB

After

Width:  |  Height:  |  Size: 5.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 170 KiB

After

Width:  |  Height:  |  Size: 8.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 30 KiB

After

Width:  |  Height:  |  Size: 5.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 170 KiB

After

Width:  |  Height:  |  Size: 20 KiB

+10 -14
View File
@@ -1,4 +1,6 @@
<!DOCTYPE html><html><head>
<!DOCTYPE html>
<html>
<head>
<!--
If you are serving your web app in a path other than the root, change the
href value below to reflect the base path you are serving from.
@@ -16,27 +18,21 @@
<meta charset="UTF-8">
<meta content="IE=Edge" http-equiv="X-UA-Compatible">
<meta name="description" content="Universal BLE">
<meta name="description" content="Example app for Web.">
<!-- iOS meta tags & icons -->
<meta name="apple-mobile-web-app-capable" content="yes">
<meta name="apple-mobile-web-app-status-bar-style" content="black">
<meta name="apple-mobile-web-app-title" content="Universal BLE">
<meta name="apple-mobile-web-app-title" content="Example app for Web.">
<link rel="apple-touch-icon" href="icons/Icon-192.png">
<!-- Favicon -->
<link rel="icon" type="image/png" href="favicon.png">
<link rel="icon" type="image/png" href="favicon.png"/>
<title>Universal BLE</title>
<title>example</title>
<link rel="manifest" href="manifest.json">
</head>
<body>
<script src="flutter_bootstrap.js" async=""></script>
</body></html>
<script src="flutter_bootstrap.js" async></script>
</body>
</html>
+1 -1
View File
@@ -5,7 +5,7 @@
"display": "standalone",
"background_color": "#0175C2",
"theme_color": "#0175C2",
"description": "Universal BLE",
"description": "Example app for Web",
"orientation": "portrait-primary",
"prefer_related_applications": false,
"icons": [
+1 -2
View File
@@ -3,8 +3,7 @@ cmake_minimum_required(VERSION 3.14)
project(universal_ble_example LANGUAGES CXX)
# The name of the executable created for the application. Change this to change
# the on-disk name of your application. Must match the CMake project/target name
# expected by Flutter (e.g. from pubspec name) or TARGET_FILE_DIR references fail.
# the on-disk name of your application.
set(BINARY_NAME "universal_ble_example")
# Explicitly opt in to modern CMake behaviors to avoid warnings with recent
@@ -7,11 +7,8 @@
#include "generated_plugin_registrant.h"
#include <universal_ble/universal_ble_plugin_c_api.h>
#include <url_launcher_windows/url_launcher_windows.h>
void RegisterPlugins(flutter::PluginRegistry* registry) {
UniversalBlePluginCApiRegisterWithRegistrar(
registry->GetRegistrarForPlugin("UniversalBlePluginCApi"));
UrlLauncherWindowsRegisterWithRegistrar(
registry->GetRegistrarForPlugin("UrlLauncherWindows"));
}

Some files were not shown because too many files have changed in this diff Show More