Improve example app UI (#193)

* Improve example app ui

* Store favorite services in local storage

* Use Text instead of SelectableText

* Improve colors

* Fix flutter analyzer issues

* Revert example app minimum sdk requirement

* Implement hasPermission api and improve ui flow

* More ui improvements

* Improve ScannedItem ui

* Improve control ui

* Change color theme to blue

* Minor improvements wip

* improve responsive ui and flow

* More improvements

* More improvements

* Update Mac podfile

* Fix home page scroll view

* Update app and package name

* Revert changelog

* Resolve Ai comments

---------

Co-authored-by: Foti Dim <foti@navideck.com>
This commit is contained in:
Rohit Sangwan
2025-12-21 13:21:36 +05:30
committed by GitHub
parent a7e280be29
commit 895dcbe343
90 changed files with 4928 additions and 1147 deletions
@@ -270,7 +270,7 @@ class UniversalBlePlugin : UniversalBlePlatformChannel, BluetoothGattCallback(),
UniversalBleLogger.logError("Device might be connected but not known to this app")
BleConnectionState.Disconnected.value
}
} catch (e: Exception) {
} catch (_: Exception) {
return BleConnectionState.Disconnected.value
}
}
+2 -2
View File
@@ -23,7 +23,7 @@ if (flutterVersionName == null) {
}
android {
namespace "com.navideck.universal_ble_example"
namespace = "com.navideck.universalble"
compileSdkVersion 36
ndkVersion flutter.ndkVersion
@@ -41,7 +41,7 @@ android {
}
defaultConfig {
applicationId "com.navideck.universal_ble_example"
applicationId = "com.navideck.universalble"
// 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_example"
android:label="Universal BLE"
android:name="${applicationName}"
android:icon="@mipmap/ic_launcher">
<activity
@@ -1,4 +1,4 @@
package com.navideck.universal_ble_example
package com.navideck.universalble
import io.flutter.embedding.android.FlutterActivity
Binary file not shown.

Before

Width:  |  Height:  |  Size: 544 B

After

Width:  |  Height:  |  Size: 6.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 442 B

After

Width:  |  Height:  |  Size: 3.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 721 B

After

Width:  |  Height:  |  Size: 10 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.0 KiB

After

Width:  |  Height:  |  Size: 20 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.4 KiB

After

Width:  |  Height:  |  Size: 30 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 718 KiB

+3
View File
@@ -0,0 +1,3 @@
description: This file stores settings for Dart & Flutter DevTools.
documentation: https://docs.flutter.dev/tools/devtools/extensions#configure-extension-enablement-states
extensions:
+27
View File
@@ -0,0 +1,27 @@
# 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.0'
platform :ios, '13.1'
# CocoaPods analytics sends network stats synchronously affecting flutter build latency.
ENV['COCOAPODS_DISABLE_STATS'] = 'true'
+14 -1
View File
@@ -2,6 +2,11 @@ PODS:
- Flutter (1.0.0)
- integration_test (0.0.1):
- Flutter
- package_info (0.0.1):
- Flutter
- shared_preferences_foundation (0.0.1):
- Flutter
- FlutterMacOS
- universal_ble (0.0.1):
- Flutter
- FlutterMacOS
@@ -9,6 +14,8 @@ PODS:
DEPENDENCIES:
- Flutter (from `Flutter`)
- integration_test (from `.symlinks/plugins/integration_test/ios`)
- package_info (from `.symlinks/plugins/package_info/ios`)
- shared_preferences_foundation (from `.symlinks/plugins/shared_preferences_foundation/darwin`)
- universal_ble (from `.symlinks/plugins/universal_ble/darwin`)
EXTERNAL SOURCES:
@@ -16,14 +23,20 @@ EXTERNAL SOURCES:
:path: Flutter
integration_test:
:path: ".symlinks/plugins/integration_test/ios"
package_info:
:path: ".symlinks/plugins/package_info/ios"
shared_preferences_foundation:
:path: ".symlinks/plugins/shared_preferences_foundation/darwin"
universal_ble:
:path: ".symlinks/plugins/universal_ble/darwin"
SPEC CHECKSUMS:
Flutter: cabc95a1d2626b1b06e7179b784ebcf0c0cde467
integration_test: 252f60fa39af5e17c3aa9899d35d908a0721b573
package_info: 873975fc26034f0b863a300ad47e7f1ac6c7ec62
shared_preferences_foundation: 5086985c1d43c5ba4d5e69a4e8083a389e2909e6
universal_ble: 65e1257dffc557cc7991a93d253beeddc7c1dc92
PODFILE CHECKSUM: 4f1c12611da7338d21589c0b2ecd6bd20b109694
PODFILE CHECKSUM: 65200e6210154a397af888bf7e7c86dbadc6d523
COCOAPODS: 1.16.2
+6 -6
View File
@@ -475,7 +475,7 @@
"$(inherited)",
"@executable_path/Frameworks",
);
PRODUCT_BUNDLE_IDENTIFIER = com.navideck.universalBleExample;
PRODUCT_BUNDLE_IDENTIFIER = com.navideck.universalble;
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.universalBleExample.RunnerTests;
PRODUCT_BUNDLE_IDENTIFIER = com.navideck.universalble.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.universalBleExample.RunnerTests;
PRODUCT_BUNDLE_IDENTIFIER = com.navideck.universalble.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.universalBleExample.RunnerTests;
PRODUCT_BUNDLE_IDENTIFIER = com.navideck.universalble.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.universalBleExample;
PRODUCT_BUNDLE_IDENTIFIER = com.navideck.universalble;
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.universalBleExample;
PRODUCT_BUNDLE_IDENTIFIER = com.navideck.universalble;
PRODUCT_NAME = "$(TARGET_NAME)";
SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h";
SWIFT_VERSION = 5.0;
@@ -1,122 +1 @@
{
"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"
}
}
{"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"}}
Binary file not shown.

Before

Width:  |  Height:  |  Size: 11 KiB

After

Width:  |  Height:  |  Size: 663 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 295 B

After

Width:  |  Height:  |  Size: 820 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 406 B

After

Width:  |  Height:  |  Size: 2.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 450 B

After

Width:  |  Height:  |  Size: 4.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 282 B

After

Width:  |  Height:  |  Size: 1.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 462 B

After

Width:  |  Height:  |  Size: 4.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 704 B

After

Width:  |  Height:  |  Size: 8.7 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 406 B

After

Width:  |  Height:  |  Size: 2.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 586 B

After

Width:  |  Height:  |  Size: 7.7 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 862 B

After

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 862 B

After

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.6 KiB

After

Width:  |  Height:  |  Size: 28 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 20 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 762 B

After

Width:  |  Height:  |  Size: 7.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.2 KiB

After

Width:  |  Height:  |  Size: 21 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.4 KiB

After

Width:  |  Height:  |  Size: 24 KiB

+1 -1
View File
@@ -13,7 +13,7 @@
<key>CFBundleInfoDictionaryVersion</key>
<string>6.0</string>
<key>CFBundleName</key>
<string>universal_ble_example</string>
<string>Universal BLE</string>
<key>CFBundlePackageType</key>
<string>APPL</string>
<key>CFBundleShortVersionString</key>
+25 -13
View File
@@ -5,6 +5,7 @@ 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',
@@ -15,12 +16,20 @@ class MockUniversalBle extends UniversalBlePlatform {
Uint8List _serviceValue = utf8.encode('Result');
bool _isScanning = false;
final BleService _mockService = BleService('180', [
BleCharacteristic('180A', [
CharacteristicProperty.read,
CharacteristicProperty.write,
CharacteristicProperty.notify,
], []),
final BleService _mockService = BleService('180a', [
BleCharacteristic.withMetaData(
deviceId: 'MockDeviceId',
serviceId: '180a',
uuid: '220a',
properties: [
CharacteristicProperty.read,
CharacteristicProperty.write,
CharacteristicProperty.notify,
],
descriptors: [
BleDescriptor('220b'),
],
),
]);
@override
@@ -45,11 +54,13 @@ class MockUniversalBle extends UniversalBlePlatform {
@override
Future<void> connect(String deviceId, {Duration? connectionTimeout}) async {
updateConnection(deviceId, true);
_connectionStateMap[deviceId] = BleConnectionState.connected;
}
@override
Future<void> disconnect(String deviceId) async {
updateConnection(deviceId, false);
_connectionStateMap[deviceId] = BleConnectionState.disconnected;
}
@override
@@ -71,7 +82,7 @@ class MockUniversalBle extends UniversalBlePlatform {
@override
Future<List<BleDevice>> getSystemDevices(List<String>? withServices) async {
return [];
return [_mockBleDevice];
}
@override
@@ -124,17 +135,18 @@ class MockUniversalBle extends UniversalBlePlatform {
}
@override
Future<BleConnectionState> getConnectionState(String deviceId) {
throw UnimplementedError();
Future<BleConnectionState> getConnectionState(String deviceId) async {
return _connectionStateMap[deviceId] ?? BleConnectionState.disconnected;
}
@override
Future<bool> disableBluetooth() {
throw UnimplementedError();
Future<bool> disableBluetooth() async {
return true;
}
@override
Future<void> requestPermissions({bool withAndroidFineLocation = false}) {
throw UnimplementedError();
Future<void> requestPermissions(
{bool withAndroidFineLocation = false}) async {
return;
}
}
+22
View File
@@ -0,0 +1,22 @@
import 'package:shared_preferences/shared_preferences.dart';
class StorageService {
StorageService._();
static StorageService? _instance;
static StorageService get instance => _instance ??= StorageService._();
late SharedPreferencesWithCache _preferences;
Future<void> init() async {
_preferences = await SharedPreferencesWithCache.create(
cacheOptions: const SharedPreferencesWithCacheOptions(),
);
}
Future<void> setFavoriteServices(List<String> services) async {
await _preferences.setStringList('favorite_services', services);
}
List<String> getFavoriteServices() =>
_preferences.getStringList('favorite_services') ?? [];
}
+7
View File
@@ -0,0 +1,7 @@
bool isSystemService(String uuid) {
final normalized = uuid.toUpperCase().replaceAll('-', '');
return normalized == '00001800' ||
normalized == '00001801' ||
normalized == '0000180A' ||
normalized.startsWith('000018');
}
-356
View File
@@ -1,356 +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/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
@@ -0,0 +1,242 @@
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...'
: 'Grant Permissions',
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(),
],
),
),
),
),
);
}
}
+655
View File
@@ -0,0 +1,655 @@
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/ble_availability_icon.dart';
import 'package:universal_ble_example/home/widgets/drawer.dart';
import 'package:universal_ble_example/home/widgets/queue_selector_widget.dart';
import 'package:universal_ble_example/home/widgets/scan_filter_widget.dart';
import 'package:universal_ble_example/home/widgets/scanned_devices_placeholder_widget.dart';
import 'package:universal_ble_example/home/widgets/scanned_item_widget.dart';
import 'package:universal_ble_example/peripheral_details/peripheral_detail_page.dart';
class ScannerScreen extends StatefulWidget {
const ScannerScreen({super.key});
@override
State createState() => _ScannerScreenState();
}
class _ScannerScreenState extends State<ScannerScreen> {
final _bleDevices = <BleDevice>[];
final _hiddenDevices = <BleDevice>[];
bool _isScanning = false;
QueueType _queueType = QueueType.global;
TextEditingController servicesFilterController = TextEditingController();
TextEditingController namePrefixController = TextEditingController();
TextEditingController manufacturerDataController = TextEditingController();
final TextEditingController _searchFilterController = TextEditingController();
final TextEditingController _webServicesController = TextEditingController();
StreamSubscription<BleDevice>? _scanSubscription;
AvailabilityState? bleAvailabilityState;
ScanFilter? scanFilter;
final Map<String, bool> _isExpanded = {};
@override
void initState() {
super.initState();
if (const bool.fromEnvironment('MOCK')) {
UniversalBle.setInstance(MockUniversalBle());
}
UniversalBle.queueType = _queueType;
UniversalBle.timeout = const Duration(seconds: 10);
_scanSubscription = UniversalBle.scanStream.listen(_handleScanResult);
UniversalBle.isScanning().then(
(isScanning) => setState(() => _isScanning = isScanning),
);
}
void _handleScanResult(BleDevice result) {
if (_hiddenDevices.any((e) => e.deviceId == result.deviceId)) {
return;
}
int index = _bleDevices.indexWhere((e) => e.deviceId == result.deviceId);
if (index == -1) {
_bleDevices.add(result);
} else {
if (result.name == null && _bleDevices[index].name != null) {
result.name = _bleDevices[index].name;
}
_bleDevices[index] = result;
}
setState(() {});
}
Future<void> _startScan() async {
setState(() {
_bleDevices.clear();
_isScanning = true;
});
try {
PlatformConfig? platformConfig;
if (kIsWeb && _webServicesController.text.isNotEmpty) {
List<String> webServices = _webServicesController.text
.split(',')
.where((s) => s.trim().isNotEmpty)
.map((s) {
try {
return BleUuidParser.string(s.trim());
} catch (_) {
return s.trim();
}
}).toList();
platformConfig = PlatformConfig(
web: WebOptions(optionalServices: webServices),
);
}
await UniversalBle.startScan(
scanFilter: scanFilter,
platformConfig: platformConfig,
);
} catch (e) {
setState(() {
_isScanning = false;
});
showSnackbar(e.toString());
}
}
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)));
}
List<BleDevice> get _filteredDevices {
if (_searchFilterController.text.isEmpty) {
return _bleDevices;
}
final filter = _searchFilterController.text.toLowerCase();
return _bleDevices.where((device) {
final name = device.name?.toLowerCase() ?? '';
final deviceId = device.deviceId.toLowerCase();
final services = device.services.join(' ').toLowerCase();
return name.contains(filter) ||
deviceId.contains(filter) ||
services.contains(filter);
}).toList();
}
bool get _isBluetoothAvailable =>
bleAvailabilityState == AvailabilityState.poweredOn;
@override
void setState(VoidCallback fn) {
if (mounted) super.setState(fn);
}
@override
void dispose() {
servicesFilterController.dispose();
namePrefixController.dispose();
manufacturerDataController.dispose();
_searchFilterController.dispose();
_webServicesController.dispose();
_scanSubscription?.cancel();
super.dispose();
}
void _showQueueBottomSheet() {
showModalBottomSheet(
context: context,
builder: (context) => QueueSelectorWidget(
queueType: _queueType,
onQueueTypeChanged: (queueType) {
setState(() {
_queueType = queueType;
UniversalBle.queueType = _queueType;
});
Navigator.pop(context);
},
),
);
}
@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(
'Scanner',
style: TextStyle(
fontWeight: FontWeight.bold,
letterSpacing: 0.5,
),
),
),
],
),
elevation: 0,
backgroundColor: colorScheme.surface,
leading: Builder(
builder: (context) => IconButton(
icon: const Icon(Icons.menu),
onPressed: () => Scaffold.of(context).openDrawer(),
),
),
actions: [
IconButton(
icon: Icon(
_isScanning ? Icons.stop_circle : Icons.play_arrow,
color: _isScanning ? colorScheme.error : colorScheme.primary,
),
tooltip: _isScanning ? 'Stop Scan' : 'Start Scan',
onPressed: _isBluetoothAvailable
? () async {
if (_isScanning) {
await UniversalBle.stopScan();
setState(() {
_isScanning = false;
});
} else {
await _startScan();
}
}
: null,
),
IconButton(
icon: Icon(
Icons.queue,
color: colorScheme.onSurface,
),
tooltip: 'Queue Type',
onPressed: _showQueueBottomSheet,
),
],
),
body: Column(
children: [
// Search filter
Padding(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
child: Container(
decoration: BoxDecoration(
color: colorScheme.surfaceContainerHighest,
borderRadius: BorderRadius.circular(12),
boxShadow: [
BoxShadow(
color: Colors.black.withValues(alpha: 0.05),
blurRadius: 8,
offset: const Offset(0, 2),
),
],
),
child: TextField(
controller: _searchFilterController,
decoration: InputDecoration(
hintText: 'Search by name, ID, or services...',
prefixIcon: Icon(
Icons.search,
color: colorScheme.primary,
),
suffixIcon: _searchFilterController.text.isNotEmpty
? IconButton(
icon: Icon(
Icons.clear,
color: colorScheme.onSurface.withValues(alpha: 0.6),
),
onPressed: () {
_searchFilterController.clear();
setState(() {});
},
)
: null,
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(12),
borderSide: BorderSide.none,
),
filled: true,
fillColor: colorScheme.surfaceContainerHighest,
contentPadding: const EdgeInsets.symmetric(
horizontal: 16,
vertical: 16,
),
),
onChanged: (_) => setState(() {}),
),
),
),
// Web Services input (only for web)
if (kIsWeb)
Padding(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
child: Container(
decoration: BoxDecoration(
color: colorScheme.surfaceContainerHighest,
borderRadius: BorderRadius.circular(12),
border: Border.all(
color: colorScheme.primary.withValues(alpha: 0.3),
width: 1,
),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Padding(
padding: const EdgeInsets.fromLTRB(16, 12, 16, 8),
child: Row(
children: [
Icon(
Icons.web,
size: 18,
color: colorScheme.primary,
),
const SizedBox(width: 8),
Text(
'Ble Services',
style: TextStyle(
fontWeight: FontWeight.w600,
fontSize: 13,
color: colorScheme.onSurface,
),
),
],
),
),
Padding(
padding: const EdgeInsets.fromLTRB(16, 0, 16, 12),
child: TextFormField(
controller: _webServicesController,
maxLines: 2,
style: const TextStyle(
fontFamily: 'monospace', fontSize: 12),
decoration: InputDecoration(
hintText:
'Enter service UUIDs for web (comma-separated)',
helperText:
'These services will be available to use after connection on Web',
helperMaxLines: 2,
helperStyle: TextStyle(
fontSize: 11,
color: colorScheme.onSurface.withValues(alpha: 0.6),
),
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(8),
borderSide: BorderSide.none,
),
filled: true,
fillColor: colorScheme.surface,
contentPadding: const EdgeInsets.symmetric(
horizontal: 12,
vertical: 12,
),
),
),
),
],
),
),
),
// Device count badge with hide/unhide
Padding(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
child: Row(
children: [
Tooltip(
message: _hiddenDevices.isNotEmpty
? 'Show hidden devices'
: _bleDevices.isNotEmpty
? 'Hide already discovered devices. When you turn on a new device, it will be easier to spot.'
: '',
child: TextButton.icon(
onPressed:
_bleDevices.isNotEmpty || _hiddenDevices.isNotEmpty
? () {
if (_hiddenDevices.isNotEmpty) {
// Unhide all devices
setState(() {
_hiddenDevices.clear();
});
} else if (_bleDevices.isNotEmpty) {
// Hide all devices
setState(() {
_hiddenDevices.clear();
_hiddenDevices.addAll(_bleDevices);
_bleDevices.clear();
});
}
}
: null,
label: Row(
mainAxisSize: MainAxisSize.min,
children: [
Text(
'${_bleDevices.length} ${_hiddenDevices.isNotEmpty ? "/ ${_hiddenDevices.length}" : ""} device${_bleDevices.length == 1 ? '' : 's'}',
style: TextStyle(
color: colorScheme.onPrimaryContainer,
fontSize: 12,
fontWeight: FontWeight.w600,
),
),
const SizedBox(width: 4),
Icon(
_hiddenDevices.isNotEmpty
? Icons.visibility
: Icons.visibility_off,
size: 16,
color: colorScheme.onPrimaryContainer,
),
],
),
style: TextButton.styleFrom(
padding: EdgeInsets.symmetric(horizontal: 8),
backgroundColor: colorScheme.primaryContainer,
),
),
),
const Spacer(),
TextButton.icon(
onPressed: _showScanFilterBottomSheet,
icon: Icon(
scanFilter != null
? Icons.filter_list
: Icons.filter_list_outlined,
size: 18,
color: scanFilter != null
? colorScheme.primary
: colorScheme.onSurface,
),
label: Text(
'Filter',
style: TextStyle(
color: scanFilter != null
? colorScheme.primary
: colorScheme.onSurface,
fontSize: 12,
fontWeight: FontWeight.w600,
),
),
style: TextButton.styleFrom(
padding: EdgeInsets.symmetric(horizontal: 8),
backgroundColor: scanFilter != null
? colorScheme.primaryContainer
: null,
),
),
const SizedBox(width: 4),
TextButton.icon(
onPressed: () {
setState(() {
_bleDevices.clear();
});
},
icon: Icon(
Icons.clear_all,
size: 18,
color: colorScheme.error,
),
label: Text(
'Clear',
style: TextStyle(
color: colorScheme.error,
fontSize: 12,
fontWeight: FontWeight.w600,
),
),
style: TextButton.styleFrom(
padding: EdgeInsets.symmetric(horizontal: 8),
),
),
],
),
),
const Divider(),
// Devices List
Expanded(
child: !_isBluetoothAvailable
? Center(
child: Padding(
padding: const EdgeInsets.all(24.0),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Container(
padding: const EdgeInsets.all(32),
decoration: BoxDecoration(
color: colorScheme.errorContainer.withValues(
alpha: 0.3,
),
shape: BoxShape.circle,
),
child: Icon(
Icons.bluetooth_disabled,
size: 80,
color: colorScheme.error,
),
),
const SizedBox(height: 32),
Text(
'Bluetooth is Turned Off',
style: TextStyle(
fontSize: 24,
fontWeight: FontWeight.bold,
color: colorScheme.onSurface,
),
textAlign: TextAlign.center,
),
const SizedBox(height: 16),
Text(
'Please turn on Bluetooth to scan for devices',
style: TextStyle(
fontSize: 16,
color: colorScheme.onSurface.withValues(
alpha: 0.7,
),
),
textAlign: TextAlign.center,
),
if (BleCapabilities.supportsBluetoothEnableApi) ...[
const SizedBox(height: 48),
ElevatedButton.icon(
onPressed: () async {
try {
bool isEnabled =
await UniversalBle.enableBluetooth();
if (!isEnabled) {
showSnackbar(
"Please enable Bluetooth in system settings",
);
}
} catch (e) {
showSnackbar(e.toString());
}
},
icon: const Icon(Icons.bluetooth, size: 24),
label: const Text(
'Turn On Bluetooth',
style: TextStyle(fontSize: 18),
),
style: ElevatedButton.styleFrom(
backgroundColor: colorScheme.primary,
foregroundColor: colorScheme.onPrimary,
padding: const EdgeInsets.symmetric(
horizontal: 32,
vertical: 16,
),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12),
),
elevation: 2,
),
),
]
],
),
),
)
: _isScanning && _bleDevices.isEmpty
? Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
CircularProgressIndicator.adaptive(
valueColor: AlwaysStoppedAnimation<Color>(
colorScheme.primary,
),
),
const SizedBox(height: 16),
Text(
'Scanning for devices...',
style: TextStyle(
color: colorScheme.onSurface
.withValues(alpha: 0.6),
fontSize: 14,
),
),
],
),
)
: !_isScanning && _bleDevices.isEmpty
? ScannedDevicesPlaceholderWidget(onTap: _startScan)
: _filteredDevices.isEmpty
? Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(
Icons.search_off,
size: 64,
color: colorScheme.onSurface
.withValues(alpha: 0.3),
),
const SizedBox(height: 16),
Text(
'No devices match your filter',
style: TextStyle(
color: colorScheme.onSurface
.withValues(alpha: 0.6),
fontSize: 16,
),
),
],
),
)
: ListView.separated(
padding: const EdgeInsets.symmetric(
horizontal: 16,
vertical: 8,
),
itemCount: _filteredDevices.length,
separatorBuilder: (context, index) =>
const SizedBox(height: 8),
itemBuilder: (context, index) {
BleDevice device = _filteredDevices[
_filteredDevices.length - index - 1];
return ScannedItemWidget(
bleDevice: device,
isExpanded:
_isExpanded[device.deviceId] ?? false,
onExpand: (isExpanded) {
setState(() {
_isExpanded[device.deviceId] =
isExpanded;
});
},
onTap: () {
Navigator.push(
context,
MaterialPageRoute(
builder: (_) =>
PeripheralDetailPage(device),
),
);
// Stop scan but keep results visible
UniversalBle.stopScan();
setState(() {
_isScanning = false;
});
},
);
},
),
),
],
),
);
}
}
+379
View File
@@ -0,0 +1,379 @@
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),
),
);
},
);
},
),
),
],
),
);
}
}
@@ -0,0 +1,73 @@
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),
),
};
}
}
@@ -0,0 +1,30 @@
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'}',
),
],
),
);
}
}
+109
View File
@@ -0,0 +1,109 @@
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/scanner_screen.dart';
import 'package:universal_ble_example/home/system_devices_screen.dart';
import 'package:url_launcher/url_launcher.dart';
class AppDrawer extends StatefulWidget {
const AppDrawer({super.key});
@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,
),
),
),
],
),
),
),
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()),
),
const Divider(),
FutureBuilder(
future: PackageInfo.fromPlatform(),
builder: (_, snapshot) => 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(
children: [
TextSpan(
text: 'Learn More',
style: const TextStyle(
color: Colors.blue,
decoration: TextDecoration.underline,
),
recognizer: TapGestureRecognizer()
..onTap = () {
launchUrl(Uri.parse(
"https://github.com/Navideck/universal_ble"));
},
),
],
),
),
],
),
),
],
),
);
}
}
@@ -0,0 +1,157 @@
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,
),
],
),
),
);
}
}
@@ -0,0 +1,63 @@
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'),
],
);
}
}
+267 -69
View File
@@ -1,6 +1,5 @@
import 'package:flutter/material.dart';
import 'package:universal_ble/universal_ble.dart';
import 'package:universal_ble_example/widgets/platform_button.dart';
class ScanFilterWidget extends StatefulWidget {
final void Function(ScanFilter? filter) onScanFilter;
@@ -32,9 +31,13 @@ class _ScanFilterWidgetState extends State<ScanFilterWidget> {
List<String> namePrefixes = [];
List<ManufacturerDataFilter> manufacturerDataFilters = [];
// Parse Services
// Parse Services - handle both comma and newline separated
if (widget.servicesFilterController.text.isNotEmpty) {
List<String> services = widget.servicesFilterController.text.split(',');
List<String> services = widget.servicesFilterController.text
.split(',')
.map((e) => e.trim())
.where((s) => s.isNotEmpty)
.toList();
for (String service in services) {
try {
serviceUUids.add(BleUuidParser.string(service.trim()));
@@ -44,18 +47,34 @@ class _ScanFilterWidgetState extends State<ScanFilterWidget> {
}
}
// Parse Name Prefix
// Parse Name Prefix - handle both comma and newline separated
String namePrefix = widget.namePrefixController.text;
if (namePrefix.isNotEmpty) {
namePrefixes = namePrefix.split(',').map((e) => e.trim()).toList();
}
// Parse Manufacturer Data
// Parse Manufacturer Data - handle both comma and newline separated
String manufacturerDataText = widget.manufacturerDataController.text;
if (manufacturerDataText.isNotEmpty) {
List<String> manufacturerData = manufacturerDataText.split(',');
List<String> manufacturerData = manufacturerDataText
.split(',')
.map((e) => e.trim())
.where((s) => s.isNotEmpty)
.toList();
for (String manufacturer in manufacturerData) {
int? companyIdentifier = int.tryParse(manufacturer);
String trimmed = manufacturer.trim();
// Remove 0x prefix if present, otherwise parse as decimal or hex
int? companyIdentifier;
if (trimmed.toLowerCase().startsWith('0x')) {
companyIdentifier = int.tryParse(trimmed.substring(2), radix: 16);
} else {
// Try parsing as hex first (if it contains letters), then decimal
if (trimmed.contains(RegExp(r'[a-fA-F]'))) {
companyIdentifier = int.tryParse(trimmed, radix: 16);
} else {
companyIdentifier = int.tryParse(trimmed);
}
}
if (companyIdentifier == null) {
throw Exception("Invalid Manufacturer Data $manufacturer");
}
@@ -98,74 +117,253 @@ class _ScanFilterWidgetState extends State<ScanFilterWidget> {
@override
Widget build(BuildContext context) {
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,
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,
),
),
),
],
),
),
// 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",
helperText: "Company identifiers in decimal or hex format",
),
const SizedBox(height: 24),
// Action Buttons
Row(
children: [
Expanded(
flex: 2,
child: OutlinedButton.icon(
onPressed: clearFilter,
icon: const Icon(Icons.clear_all),
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),
],
),
),
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(),
),
),
);
}
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 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(height: 4),
TextFormField(
controller: controller,
maxLines: maxLines,
style: const TextStyle(fontFamily: 'monospace'),
decoration: InputDecoration(
hintText: hintText,
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(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,26 +1,58 @@
import 'package:flutter/material.dart';
class ScannedDevicesPlaceholderWidget extends StatelessWidget {
const ScannedDevicesPlaceholderWidget({super.key});
final Function() onTap;
const ScannedDevicesPlaceholderWidget({super.key, required this.onTap});
@override
Widget build(BuildContext context) {
return const Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Padding(
padding: EdgeInsets.all(8.0),
child: Icon(
Icons.bluetooth,
color: Colors.grey,
size: 100,
),
final theme = Theme.of(context);
final colorScheme = theme.colorScheme;
return Center(
child: SingleChildScrollView(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
InkWell(
onTap: onTap,
child: 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: 24),
Text(
'No Devices Found',
style: TextStyle(
color: colorScheme.onSurface,
fontSize: 24,
fontWeight: FontWeight.bold,
),
),
const SizedBox(height: 8),
Padding(
padding: const EdgeInsets.symmetric(horizontal: 48),
child: Text(
'Tap Scan button to discover nearby Bluetooth devices',
textAlign: TextAlign.center,
style: TextStyle(
color: colorScheme.onSurface.withValues(alpha: 0.6),
fontSize: 14,
),
),
),
],
),
Text(
'Scan For Devices',
style: TextStyle(color: Colors.grey, fontSize: 22),
)
],
),
);
}
}
+399 -31
View File
@@ -1,50 +1,418 @@
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:universal_ble/universal_ble.dart';
import 'package:universal_ble_example/home/widgets/rssi_signal_indicator.dart';
class ScannedItemWidget extends StatelessWidget {
final BleDevice bleDevice;
final VoidCallback? onTap;
const ScannedItemWidget({super.key, required this.bleDevice, this.onTap});
final bool isExpanded;
final Function(bool) onExpand;
const ScannedItemWidget({
super.key,
required this.bleDevice,
this.onTap,
required this.isExpanded,
required this.onExpand,
});
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final colorScheme = theme.colorScheme;
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(
child: ListTile(
title: Text(
'$name (${bleDevice.rssi})',
),
subtitle: Column(
if (name == null || name.isEmpty) name = 'Unknown Device';
return Card(
elevation: 2,
margin: EdgeInsets.zero,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(16),
),
child: InkWell(
onTap: onTap,
borderRadius: BorderRadius.circular(16),
child: Padding(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(bleDevice.deviceId),
Visibility(
visible: manufacturerData != null,
child: Text(manufacturerData.toString()),
),
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),
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: 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 ||
bleDevice.services.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(
isExpanded
? Icons.expand_less
: Icons.expand_more,
color: colorScheme.primary,
size: 18,
),
onPressed: () {
onExpand(!isExpanded);
},
tooltip: 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(
bleDevice.deviceId,
style: TextStyle(
fontSize: 12,
color: colorScheme.onSurface
.withValues(alpha: 0.7),
fontFamily: 'monospace',
),
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: bleDevice.paired == true
? Colors.green.withValues(alpha: 0.2)
: Colors.orange.withValues(alpha: 0.2),
borderRadius: BorderRadius.circular(8),
),
child: Text(
bleDevice.paired == true
? 'Paired'
: 'Unpaired',
style: TextStyle(
fontSize: 10,
fontWeight: FontWeight.w600,
color: bleDevice.paired == true
? Colors.green.shade700
: Colors.orange.shade700,
),
),
),
// Manufacturer data (only in collapsed mode)
if (!isExpanded) ...[
...rawManufacturerData.take(2).map((data) {
return Container(
padding: const EdgeInsets.symmetric(
horizontal: 8,
vertical: 4,
),
decoration: BoxDecoration(
color: colorScheme.secondaryContainer,
borderRadius: BorderRadius.circular(8),
),
child: Text(
data.companyIdRadix16,
style: TextStyle(
fontSize: 10,
color: colorScheme.onSecondaryContainer,
fontWeight: FontWeight.w500,
),
),
);
}),
],
// Services (only in collapsed mode)
if (!isExpanded) ...[
...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 (bleDevice.services.length > 3)
Text(
'+${bleDevice.services.length - 3} more',
style: TextStyle(
fontSize: 10,
color: colorScheme.onSurface
.withValues(alpha: 0.5),
),
),
],
],
),
],
),
),
],
),
// Expanded details
if (isExpanded) ...[
const SizedBox(height: 16),
const Divider(),
const SizedBox(height: 12),
if (rawManufacturerData.isNotEmpty) ...[
Text(
'Manufacturer Data',
style: TextStyle(
fontSize: 12,
fontWeight: FontWeight.bold,
color: colorScheme.onSurface,
),
),
const SizedBox(height: 8),
...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',
),
),
),
],
),
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 (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: 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(),
),
],
],
],
),
trailing: const Icon(Icons.arrow_forward_ios),
onTap: onTap,
),
),
);
+38 -8
View File
@@ -1,17 +1,47 @@
import 'package:flutter/material.dart';
import 'package:universal_ble/universal_ble.dart';
import 'package:universal_ble_example/home/home.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';
void main() async {
WidgetsFlutterBinding.ensureInitialized();
await UniversalBle.setLogLevel(BleLogLevel.verbose);
runApp(
MaterialApp(
await StorageService.instance.init();
// await UniversalBle.setLogLevel(BleLogLevel.verbose);
bool hasPermission = await UniversalBle.hasPermissions(
withAndroidFineLocation: false,
);
runApp(MyApp(
hasPermission: hasPermission,
));
}
class MyApp extends StatelessWidget {
final bool hasPermission;
const MyApp({super.key, required this.hasPermission});
@override
Widget build(BuildContext context) {
return MaterialApp(
title: 'Universal BLE',
debugShowCheckedModeBanner: false,
darkTheme: ThemeData.dark(),
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: const MyApp(),
),
);
home: hasPermission ? ScannerScreen() : const PermissionScreen(),
);
}
}
File diff suppressed because it is too large Load Diff
@@ -3,48 +3,196 @@ import 'package:flutter/material.dart';
class ResultWidget extends StatelessWidget {
final List<String> results;
final bool scrollable;
final ScrollController scrollController;
final void Function(int? index) onClearTap;
const ResultWidget({
required this.results,
required this.onClearTap,
this.scrollable = false,
required this.scrollController,
super.key,
});
@override
Widget build(BuildContext context) {
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),
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: Row(
children: [
Icon(
Icons.history,
color: colorScheme.primary,
size: 20,
),
const SizedBox(width: 8),
Text(
'Logs',
style: TextStyle(
fontWeight: FontWeight.bold,
fontSize: 16,
color: colorScheme.onSurface,
),
),
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]),
const Spacer(),
if (results.isNotEmpty)
Container(
padding: const EdgeInsets.symmetric(
horizontal: 8,
vertical: 4,
),
);
},
separatorBuilder: (_, __) => const Divider(),
decoration: BoxDecoration(
color: colorScheme.primaryContainer,
borderRadius: BorderRadius.circular(8),
),
child: Text(
'${results.length}',
style: TextStyle(
color: colorScheme.onPrimaryContainer,
fontSize: 12,
fontWeight: FontWeight.w600,
),
),
),
if (results.isNotEmpty) ...[
const SizedBox(width: 8),
IconButton(
icon: Icon(
Icons.clear_all,
color: colorScheme.error,
size: 20,
),
onPressed: () => onClearTap(null),
tooltip: 'Clear all logs',
padding: EdgeInsets.zero,
constraints: const BoxConstraints(
minWidth: 32,
minHeight: 32,
),
),
],
],
),
);
}
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 InkWell(
onTap: () => onClearTap(reversedIndex),
child: Container(
padding: const EdgeInsets.symmetric(
horizontal: 16,
vertical: 12,
),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Icon(
Icons.circle,
size: 6,
color: colorScheme.primary.withValues(alpha: 0.6),
),
const SizedBox(width: 12),
Expanded(
child: SelectableText(
log,
style: TextStyle(
fontSize: 12,
fontFamily: 'monospace',
color: colorScheme.onSurface,
),
),
),
const SizedBox(width: 8),
Icon(
Icons.close,
size: 16,
color: colorScheme.onSurface.withValues(alpha: 0.4),
),
],
),
),
);
}
}
@@ -1,78 +1,222 @@
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 StatelessWidget {
final List<BleService> discoveredServices;
final bool scrollable;
final void Function(
BleService service,
BleCharacteristic characteristic,
)? onTap;
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;
const ServicesListWidget({
super.key,
required this.discoveredServices,
this.onTap,
this.scrollable = false,
this.selectedService,
this.selectedCharacteristic,
this.favoriteServices,
this.subscribedCharacteristics,
this.onFavoriteToggle,
});
@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);
// Sort services: favorites first, then system services, then others
final sortedServices = List<BleService>.from(discoveredServices);
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 ListView.builder(
shrinkWrap: !scrollable,
physics: scrollable ? null : const NeverScrollableScrollPhysics(),
itemCount: discoveredServices.length,
itemCount: sortedServices.length,
itemBuilder: (BuildContext context, int index) {
final service = sortedServices[index];
final isFavorite = favoriteServices?.contains(service.uuid) ?? false;
final isSelected = selectedService?.uuid == service.uuid;
return Padding(
padding: const EdgeInsets.all(8.0),
padding: const EdgeInsets.symmetric(horizontal: 8.0, vertical: 4.0),
child: Card(
elevation: isSelected ? 2 : 1,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12),
side: isSelected
? BorderSide(color: selectedColor, width: 2)
: BorderSide.none,
),
child: ExpandablePanel(
header: Padding(
padding: const EdgeInsets.all(8.0),
padding: const EdgeInsets.all(12.0),
child: Row(
children: [
const Icon(Icons.arrow_forward_ios),
Expanded(child: Text(discoveredServices[index].uuid)),
Icon(
Icons.arrow_forward_ios,
size: 14,
color: colorScheme.onSurface.withValues(alpha: 0.6),
),
const SizedBox(width: 8),
Expanded(
child: Text(
service.uuid,
style: TextStyle(
fontSize: 13,
fontFamily: 'monospace',
color: colorScheme.onSurface,
fontWeight:
isSelected ? FontWeight.bold : FontWeight.w500,
),
),
),
if (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: () => onFavoriteToggle!(service.uuid),
iconSize: 20,
padding: EdgeInsets.zero,
constraints: const BoxConstraints(
minWidth: 32,
minHeight: 32,
),
),
],
],
),
),
collapsed: const SizedBox(),
expanded: Column(
children: discoveredServices[index]
.characteristics
.map((e) => Padding(
padding: const EdgeInsets.all(8.0),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
InkWell(
onTap: () {
onTap?.call(discoveredServices[index], e);
},
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
expanded: Padding(
padding: const EdgeInsets.only(bottom: 8.0),
child: Column(
children: service.characteristics.map((e) {
final isCharSelected =
selectedCharacteristic?.uuid == e.uuid;
final isSubscribed =
subscribedCharacteristics?[e.uuid] ?? false;
return Padding(
padding: const EdgeInsets.symmetric(
horizontal: 8.0,
vertical: 4.0,
),
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: InkWell(
onTap: () {
onTap?.call(service, e);
},
borderRadius: BorderRadius.circular(8),
child: Padding(
padding: const EdgeInsets.all(12.0),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Row(
children: [
const Icon(Icons.arrow_right_outlined),
Expanded(child: Text(e.uuid)),
],
Icon(
Icons.arrow_right_outlined,
size: 16,
color: isCharSelected
? selectedColor
: colorScheme.onSurface
.withValues(alpha: 0.6),
),
Text(
"Properties: ${e.properties.map((e) => e.name)}",
const SizedBox(width: 8),
if (isSubscribed) ...[
Icon(
Icons.notifications_active,
color: subscribedNotificationIconColor,
size: 16,
),
const SizedBox(width: 4),
],
Expanded(
child: Text(
e.uuid,
style: TextStyle(
fontSize: 12,
fontFamily: 'monospace',
fontWeight: isCharSelected
? FontWeight.bold
: FontWeight.w500,
color: isCharSelected
? selectedColor
: colorScheme.onSurface,
),
),
),
if (e.descriptors.isNotEmpty)
Text(
"Descriptors: ${e.descriptors.map((e) => e.uuid).join(', ')}",
)
],
),
),
],
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(),
),
),
),
),
@@ -0,0 +1,105 @@
import 'package:flutter/material.dart';
import 'package:universal_ble/universal_ble.dart';
class ServicesSideWidget extends StatelessWidget {
final List<BleService> discoveredServices;
final Function() serviceListBuilder;
const ServicesSideWidget({
super.key,
required this.discoveredServices,
required this.serviceListBuilder,
});
@override
Widget build(BuildContext context) {
final colorScheme = Theme.of(context).colorScheme;
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: 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(),
Container(
padding: const EdgeInsets.symmetric(
horizontal: 8,
vertical: 4,
),
decoration: BoxDecoration(
color: colorScheme.primaryContainer,
borderRadius: BorderRadius.circular(8),
),
child: Text(
'${discoveredServices.length}',
style: TextStyle(
color: colorScheme.onPrimaryContainer,
fontSize: 12,
fontWeight: FontWeight.w600,
),
),
),
],
),
),
Expanded(
child: discoveredServices.isEmpty
? Center(
child: 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),
),
),
],
),
)
: serviceListBuilder(),
),
],
),
);
}
}
+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_example")
set(BINARY_NAME "universal_ble")
# The unique GTK application identifier for this application. See:
# https://wiki.gnome.org/HowDoI/ChooseApplicationID
set(APPLICATION_ID "com.navideck.universal_ble")
set(APPLICATION_ID "com.navideck.universalble")
# Explicitly opt in to modern CMake behaviors to avoid warnings with recent
# versions of CMake.
@@ -6,6 +6,10 @@
#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,6 +3,7 @@
#
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_example");
gtk_header_bar_set_title(header_bar, "Universal BLE");
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_example");
gtk_window_set_title(window, "Universal BLE");
}
gtk_window_set_default_size(window, 1280, 720);
@@ -5,8 +5,14 @@
import FlutterMacOS
import Foundation
import package_info_plus
import shared_preferences_foundation
import universal_ble
import url_launcher_macos
func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) {
FPPPackageInfoPlusPlugin.register(with: registry.registrar(forPlugin: "FPPPackageInfoPlusPlugin"))
SharedPreferencesPlugin.register(with: registry.registrar(forPlugin: "SharedPreferencesPlugin"))
UniversalBlePlugin.register(with: registry.registrar(forPlugin: "UniversalBlePlugin"))
UrlLauncherPlugin.register(with: registry.registrar(forPlugin: "UrlLauncherPlugin"))
}
+19
View File
@@ -1,22 +1,41 @@
PODS:
- FlutterMacOS (1.0.0)
- package_info_plus (0.0.1):
- 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`)
- 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
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: 12f1c5c2cfe8727ca46cbd0b26677728972d9a5b
shared_preferences_foundation: 5086985c1d43c5ba4d5e69a4e8083a389e2909e6
universal_ble: 65e1257dffc557cc7991a93d253beeddc7c1dc92
url_launcher_macos: 175a54c831f4375a6cf895875f716ee5af3888ce
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_example.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = universal_ble_example.app; sourceTree = BUILT_PRODUCTS_DIR; };
33CC10ED2044A3C60003C045 /* Universal BLE.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = "Universal BLE.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_example.app */,
33CC10ED2044A3C60003C045 /* Universal BLE.app */,
331C80D5294CF71000263BE5 /* RunnerTests.xctest */,
);
name = Products;
@@ -248,7 +248,7 @@
);
name = Runner;
productName = Runner;
productReference = 33CC10ED2044A3C60003C045 /* universal_ble_example.app */;
productReference = 33CC10ED2044A3C60003C045 /* Universal BLE.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.universalBleExample.RunnerTests;
PRODUCT_BUNDLE_IDENTIFIER = com.navideck.universalble.RunnerTests;
PRODUCT_NAME = "$(TARGET_NAME)";
SWIFT_VERSION = 5.0;
TEST_HOST = "$(BUILT_PRODUCTS_DIR)/universal_ble_example.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/universal_ble_example";
TEST_HOST = "$(BUILT_PRODUCTS_DIR)/Universal BLE.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/Universal BLE";
};
name = Debug;
};
@@ -492,10 +492,10 @@
CURRENT_PROJECT_VERSION = 1;
GENERATE_INFOPLIST_FILE = YES;
MARKETING_VERSION = 1.0;
PRODUCT_BUNDLE_IDENTIFIER = com.navideck.universalBleExample.RunnerTests;
PRODUCT_BUNDLE_IDENTIFIER = com.navideck.universalble.RunnerTests;
PRODUCT_NAME = "$(TARGET_NAME)";
SWIFT_VERSION = 5.0;
TEST_HOST = "$(BUILT_PRODUCTS_DIR)/universal_ble_example.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/universal_ble_example";
TEST_HOST = "$(BUILT_PRODUCTS_DIR)/Universal BLE.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/Universal BLE";
};
name = Release;
};
@@ -507,10 +507,10 @@
CURRENT_PROJECT_VERSION = 1;
GENERATE_INFOPLIST_FILE = YES;
MARKETING_VERSION = 1.0;
PRODUCT_BUNDLE_IDENTIFIER = com.navideck.universalBleExample.RunnerTests;
PRODUCT_BUNDLE_IDENTIFIER = com.navideck.universalble.RunnerTests;
PRODUCT_NAME = "$(TARGET_NAME)";
SWIFT_VERSION = 5.0;
TEST_HOST = "$(BUILT_PRODUCTS_DIR)/universal_ble_example.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/universal_ble_example";
TEST_HOST = "$(BUILT_PRODUCTS_DIR)/Universal BLE.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/Universal BLE";
};
name = Profile;
};
@@ -15,7 +15,7 @@
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "33CC10EC2044A3C60003C045"
BuildableName = "universal_ble_example.app"
BuildableName = "Universal BLE.app"
BlueprintName = "Runner"
ReferencedContainer = "container:Runner.xcodeproj">
</BuildableReference>
@@ -31,7 +31,7 @@
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "33CC10EC2044A3C60003C045"
BuildableName = "universal_ble_example.app"
BuildableName = "Universal BLE.app"
BlueprintName = "Runner"
ReferencedContainer = "container:Runner.xcodeproj">
</BuildableReference>
@@ -66,7 +66,7 @@
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "33CC10EC2044A3C60003C045"
BuildableName = "universal_ble_example.app"
BuildableName = "Universal BLE.app"
BlueprintName = "Runner"
ReferencedContainer = "container:Runner.xcodeproj">
</BuildableReference>
@@ -83,7 +83,7 @@
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "33CC10EC2044A3C60003C045"
BuildableName = "universal_ble_example.app"
BuildableName = "Universal BLE.app"
BlueprintName = "Runner"
ReferencedContainer = "container:Runner.xcodeproj">
</BuildableReference>
@@ -1,68 +1,68 @@
{
"images" : [
{
"size" : "16x16",
"idiom" : "mac",
"filename" : "app_icon_16.png",
"scale" : "1x"
"info": {
"version": 1,
"author": "xcode"
},
{
"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"
}
}
"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"
}
]
}
Binary file not shown.

Before

Width:  |  Height:  |  Size: 101 KiB

After

Width:  |  Height:  |  Size: 663 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.5 KiB

After

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 520 B

After

Width:  |  Height:  |  Size: 578 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 14 KiB

After

Width:  |  Height:  |  Size: 48 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.0 KiB

After

Width:  |  Height:  |  Size: 1.7 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 36 KiB

After

Width:  |  Height:  |  Size: 170 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.2 KiB

After

Width:  |  Height:  |  Size: 5.4 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_example
PRODUCT_NAME = Universal BLE
// The application's bundle identifier
PRODUCT_BUNDLE_IDENTIFIER = com.navideck.universalBleExample
PRODUCT_BUNDLE_IDENTIFIER = com.navideck.universalble
// The copyright displayed in application information
PRODUCT_COPYRIGHT = Copyright © 2023 com.navideck. All rights reserved.
+32
View File
@@ -0,0 +1,32 @@
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"
+304 -2
View File
@@ -1,6 +1,14 @@
# 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:
@@ -41,6 +49,22 @@ packages:
url: "https://pub.dev"
source: hosted
version: "1.4.0"
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:
@@ -65,6 +89,22 @@ 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:
@@ -123,6 +163,15 @@ 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:
@@ -144,16 +193,61 @@ packages:
url: "https://pub.dev"
source: hosted
version: "1.1.0"
flutter_web_plugins:
dependency: transitive
description: flutter
source: sdk
version: "0.0.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"
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:
@@ -186,6 +280,14 @@ 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:
@@ -218,6 +320,30 @@ packages:
url: "https://pub.dev"
source: hosted
version: "1.17.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:
@@ -226,6 +352,30 @@ packages:
url: "https://pub.dev"
source: hosted
version: "1.9.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:
@@ -250,6 +400,14 @@ 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:
@@ -258,6 +416,62 @@ packages:
url: "https://pub.dev"
source: hosted
version: "5.0.5"
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
@@ -334,6 +548,70 @@ packages:
relative: true
source: path
version: "1.0.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"
vector_math:
dependency: transitive
description:
@@ -366,6 +644,22 @@ 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:
@@ -374,6 +668,14 @@ packages:
url: "https://pub.dev"
source: hosted
version: "6.6.1"
yaml:
dependency: transitive
description:
name: yaml
sha256: b9da305ac7c39faa3f030eccd175340f968459dae4af175130b3fc47e40d76ce
url: "https://pub.dev"
source: hosted
version: "3.1.3"
sdks:
dart: ">=3.8.0 <4.0.0"
flutter: ">=3.18.0-18.0.pre.54"
dart: ">=3.9.0 <4.0.0"
flutter: ">=3.35.0"
+10
View File
@@ -12,6 +12,9 @@ dependencies:
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
universal_ble:
path: ../
@@ -21,6 +24,13 @@ 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
Binary file not shown.

Before

Width:  |  Height:  |  Size: 917 B

After

Width:  |  Height:  |  Size: 578 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.2 KiB

After

Width:  |  Height:  |  Size: 30 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 8.1 KiB

After

Width:  |  Height:  |  Size: 170 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.5 KiB

After

Width:  |  Height:  |  Size: 30 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 20 KiB

After

Width:  |  Height:  |  Size: 170 KiB

+14 -10
View File
@@ -1,6 +1,4 @@
<!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.
@@ -18,21 +16,27 @@
<meta charset="UTF-8">
<meta content="IE=Edge" http-equiv="X-UA-Compatible">
<meta name="description" content="Example app for Web.">
<meta name="description" content="Universal BLE">
<!-- 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="Example app for Web.">
<meta name="apple-mobile-web-app-title" content="Universal BLE">
<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>example</title>
<title>Universal BLE</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": "Example app for Web",
"description": "Universal BLE",
"orientation": "portrait-primary",
"prefer_related_applications": false,
"icons": [
+1 -1
View File
@@ -4,7 +4,7 @@ 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.
set(BINARY_NAME "universal_ble_example")
set(BINARY_NAME "universal_ble")
# Explicitly opt in to modern CMake behaviors to avoid warnings with recent
# versions of CMake.
+6 -6
View File
@@ -89,13 +89,13 @@ BEGIN
BEGIN
BLOCK "040904e4"
BEGIN
VALUE "CompanyName", "com.navideck" "\0"
VALUE "FileDescription", "universal_ble_example" "\0"
VALUE "CompanyName", "Navideck" "\0"
VALUE "FileDescription", "Universal BLE" "\0"
VALUE "FileVersion", VERSION_AS_STRING "\0"
VALUE "InternalName", "universal_ble_example" "\0"
VALUE "LegalCopyright", "Copyright (C) 2023 com.navideck. All rights reserved." "\0"
VALUE "OriginalFilename", "universal_ble_example.exe" "\0"
VALUE "ProductName", "universal_ble_example" "\0"
VALUE "InternalName", "Universal BLE" "\0"
VALUE "LegalCopyright", "Copyright © 2023 com.navideck. All rights reserved." "\0"
VALUE "OriginalFilename", "universal_ble.exe" "\0"
VALUE "ProductName", "Universal BLE" "\0"
VALUE "ProductVersion", VERSION_AS_STRING "\0"
END
END
+1 -1
View File
@@ -27,7 +27,7 @@ int APIENTRY wWinMain(_In_ HINSTANCE instance, _In_opt_ HINSTANCE prev,
FlutterWindow window(project);
Win32Window::Point origin(10, 10);
Win32Window::Size size(1280, 720);
if (!window.Create(L"universal_ble_example", origin, size)) {
if (!window.Create(L"Universal BLE", origin, size)) {
return EXIT_FAILURE;
}
window.SetQuitOnClose(true);
Binary file not shown.

Before

Width:  |  Height:  |  Size: 33 KiB

After

Width:  |  Height:  |  Size: 83 KiB

+3
View File
@@ -8,6 +8,9 @@ class ManufacturerData {
String get companyIdRadix16 => "0x0${companyId.toRadixString(16)}";
String get payloadRadix16 =>
"0x${payload.map((e) => e.toRadixString(16).toUpperCase().padLeft(2, '0')).join('')}";
factory ManufacturerData.fromData(Uint8List data) {
if (data.length < 2) {
throw const FormatException("Invalid Manufacturer Data");