feat: everything up to bluetooth scanning
This commit is contained in:
131
lib/controller/bluetooth.dart
Normal file
131
lib/controller/bluetooth.dart
Normal file
@ -0,0 +1,131 @@
|
||||
import 'dart:async';
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:anyhow/anyhow.dart';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter_blue_plus/flutter_blue_plus.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:logging/logging.dart';
|
||||
import 'package:riverpod_annotation/riverpod_annotation.dart';
|
||||
|
||||
part 'bluetooth.g.dart';
|
||||
|
||||
final log = Logger('BluetoothController');
|
||||
|
||||
@riverpod
|
||||
Future<BluetoothController> bluetooth(Ref ref) async {
|
||||
final controller = BluetoothController();
|
||||
log.info(await controller.init());
|
||||
return controller;
|
||||
}
|
||||
|
||||
class BluetoothController {
|
||||
StreamSubscription<BluetoothAdapterState>? _btStateSubscription;
|
||||
StreamSubscription<List<ScanResult>>? _scanResultsSubscription;
|
||||
List<ScanResult> _latestScanResults = [];
|
||||
|
||||
Future<Result<void>> init() async {
|
||||
if (await FlutterBluePlus.isSupported == false) {
|
||||
log.severe("Bluetooth is not supported on this device!");
|
||||
return bail("Bluetooth is not supported on this device!");
|
||||
}
|
||||
|
||||
_btStateSubscription =
|
||||
FlutterBluePlus.adapterState.listen((BluetoothAdapterState state) {
|
||||
if (state == BluetoothAdapterState.on) {
|
||||
log.info("Bluetooth is on!");
|
||||
// usually start scanning, connecting, etc
|
||||
} else {
|
||||
log.info("Bluetooth is off!");
|
||||
// show an error to the user, etc
|
||||
}
|
||||
});
|
||||
|
||||
if (!kIsWeb && Platform.isAndroid) {
|
||||
await FlutterBluePlus.turnOn();
|
||||
}
|
||||
|
||||
return Ok(null);
|
||||
}
|
||||
|
||||
/// Start scanning for Bluetooth devices
|
||||
///
|
||||
/// [withServices] - Optional list of service UUIDs to filter devices by
|
||||
/// [withNames] - Optional list of device names to filter by
|
||||
/// [timeout] - Optional duration after which scanning will automatically stop
|
||||
Future<Result<void>> startScan({
|
||||
List<Guid>? withServices,
|
||||
List<String>? withNames,
|
||||
Duration? timeout,
|
||||
}) async {
|
||||
try {
|
||||
// Wait for Bluetooth to be enabled
|
||||
await FlutterBluePlus.adapterState
|
||||
.where((val) => val == BluetoothAdapterState.on)
|
||||
.first;
|
||||
|
||||
// Set up scan results listener
|
||||
_scanResultsSubscription = FlutterBluePlus.onScanResults.listen(
|
||||
(results) {
|
||||
if (results.isNotEmpty) {
|
||||
_latestScanResults = results;
|
||||
ScanResult latestResult = results.last;
|
||||
log.info(
|
||||
'${latestResult.device.remoteId}: "${latestResult.advertisementData.advName}" found!');
|
||||
}
|
||||
},
|
||||
onError: (e) {
|
||||
log.severe('Scan error: $e');
|
||||
},
|
||||
);
|
||||
|
||||
// Clean up subscription when scanning completes
|
||||
FlutterBluePlus.cancelWhenScanComplete(_scanResultsSubscription!);
|
||||
|
||||
// Start scanning with optional parameters
|
||||
await FlutterBluePlus.startScan(
|
||||
withServices: withServices ?? [],
|
||||
withNames: withNames ?? [],
|
||||
timeout: timeout,
|
||||
);
|
||||
|
||||
return Ok(null);
|
||||
} catch (e) {
|
||||
return bail('Failed to start Bluetooth scan: $e');
|
||||
}
|
||||
}
|
||||
|
||||
/// Stop an ongoing Bluetooth scan
|
||||
Future<Result<void>> stopScan() async {
|
||||
try {
|
||||
await FlutterBluePlus.stopScan();
|
||||
return Ok(null);
|
||||
} catch (e) {
|
||||
return bail('Failed to stop Bluetooth scan: $e');
|
||||
}
|
||||
}
|
||||
|
||||
/// Get the latest scan results
|
||||
List<ScanResult> get scanResults => _latestScanResults;
|
||||
|
||||
/// Wait for the current scan to complete
|
||||
Future<Result<void>> waitForScanToComplete() async {
|
||||
try {
|
||||
await FlutterBluePlus.isScanning.where((val) => val == false).first;
|
||||
return Ok(null);
|
||||
} catch (e) {
|
||||
return bail('Error waiting for scan to complete: $e');
|
||||
}
|
||||
}
|
||||
|
||||
/// Check if currently scanning
|
||||
Future<bool> get isScanning async {
|
||||
return await FlutterBluePlus.isScanning.first;
|
||||
}
|
||||
|
||||
Future<Result<void>> dispose() async {
|
||||
await _scanResultsSubscription?.cancel();
|
||||
await _btStateSubscription?.cancel();
|
||||
return Ok(null);
|
||||
}
|
||||
}
|
27
lib/controller/bluetooth.g.dart
Normal file
27
lib/controller/bluetooth.g.dart
Normal file
@ -0,0 +1,27 @@
|
||||
// GENERATED CODE - DO NOT MODIFY BY HAND
|
||||
|
||||
part of 'bluetooth.dart';
|
||||
|
||||
// **************************************************************************
|
||||
// RiverpodGenerator
|
||||
// **************************************************************************
|
||||
|
||||
String _$bluetoothHash() => r'5e9c37c57e723b84dd08fd8763e7c445b3a4dbf3';
|
||||
|
||||
/// See also [bluetooth].
|
||||
@ProviderFor(bluetooth)
|
||||
final bluetoothProvider =
|
||||
AutoDisposeFutureProvider<BluetoothController>.internal(
|
||||
bluetooth,
|
||||
name: r'bluetoothProvider',
|
||||
debugGetCreateSourceHash:
|
||||
const bool.fromEnvironment('dart.vm.product') ? null : _$bluetoothHash,
|
||||
dependencies: null,
|
||||
allTransitiveDependencies: null,
|
||||
);
|
||||
|
||||
@Deprecated('Will be removed in 3.0. Use Ref instead')
|
||||
// ignore: unused_element
|
||||
typedef BluetoothRef = AutoDisposeFutureProviderRef<BluetoothController>;
|
||||
// ignore_for_file: type=lint
|
||||
// ignore_for_file: subtype_of_sealed_class, invalid_use_of_internal_member, invalid_use_of_visible_for_testing_member, deprecated_member_use_from_same_package
|
164
lib/main.dart
164
lib/main.dart
@ -1,125 +1,69 @@
|
||||
import 'package:abawo_bt_app/pages/devices_page.dart';
|
||||
import 'package:abawo_bt_app/util/sharedPrefs.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
import 'package:logging/logging.dart';
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
import 'pages/home_page.dart';
|
||||
import 'pages/settings_page.dart';
|
||||
|
||||
void main() {
|
||||
runApp(const MyApp());
|
||||
Future<void> main() async {
|
||||
Logger.root.level = Level.ALL; // defaults to Level.INFO
|
||||
Logger.root.onRecord.listen((record) {
|
||||
print('${record.level.name}: ${record.time}: ${record.message}');
|
||||
});
|
||||
WidgetsFlutterBinding.ensureInitialized();
|
||||
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
|
||||
runApp(ProviderScope(overrides: [
|
||||
// Override the unimplemented provider with the actual instance
|
||||
sharedPreferencesProvider.overrideWithValue(prefs),
|
||||
], child: const AbawoBtApp()));
|
||||
}
|
||||
|
||||
class MyApp extends StatelessWidget {
|
||||
const MyApp({super.key});
|
||||
class AbawoBtApp extends StatelessWidget {
|
||||
const AbawoBtApp({super.key});
|
||||
|
||||
// This widget is the root of your application.
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return MaterialApp(
|
||||
title: 'Flutter Demo',
|
||||
return MaterialApp.router(
|
||||
title: 'Abawo BT App',
|
||||
theme: ThemeData(
|
||||
// This is the theme of your application.
|
||||
//
|
||||
// TRY THIS: Try running your application with "flutter run". You'll see
|
||||
// the application has a purple toolbar. Then, without quitting the app,
|
||||
// try changing the seedColor in the colorScheme below to Colors.green
|
||||
// and then invoke "hot reload" (save your changes or press the "hot
|
||||
// reload" button in a Flutter-supported IDE, or press "r" if you used
|
||||
// the command line to start the app).
|
||||
//
|
||||
// Notice that the counter didn't reset back to zero; the application
|
||||
// state is not lost during the reload. To reset the state, use hot
|
||||
// restart instead.
|
||||
//
|
||||
// This works for code too, not just values: Most code changes can be
|
||||
// tested with just a hot reload.
|
||||
colorScheme: ColorScheme.fromSeed(seedColor: Colors.deepPurple),
|
||||
primarySwatch: Colors.blue,
|
||||
useMaterial3: true,
|
||||
brightness: Brightness.light,
|
||||
),
|
||||
home: const MyHomePage(title: 'Flutter Demo Home Page'),
|
||||
darkTheme: ThemeData(
|
||||
primarySwatch: Colors.blue,
|
||||
useMaterial3: true,
|
||||
brightness: Brightness.dark,
|
||||
),
|
||||
themeMode: ThemeMode.system,
|
||||
routerConfig: _router,
|
||||
debugShowCheckedModeBanner: false,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class MyHomePage extends StatefulWidget {
|
||||
const MyHomePage({super.key, required this.title});
|
||||
|
||||
// This widget is the home page of your application. It is stateful, meaning
|
||||
// that it has a State object (defined below) that contains fields that affect
|
||||
// how it looks.
|
||||
|
||||
// This class is the configuration for the state. It holds the values (in this
|
||||
// case the title) provided by the parent (in this case the App widget) and
|
||||
// used by the build method of the State. Fields in a Widget subclass are
|
||||
// always marked "final".
|
||||
|
||||
final String title;
|
||||
|
||||
@override
|
||||
State<MyHomePage> createState() => _MyHomePageState();
|
||||
}
|
||||
|
||||
class _MyHomePageState extends State<MyHomePage> {
|
||||
int _counter = 0;
|
||||
|
||||
void _incrementCounter() {
|
||||
setState(() {
|
||||
// This call to setState tells the Flutter framework that something has
|
||||
// changed in this State, which causes it to rerun the build method below
|
||||
// so that the display can reflect the updated values. If we changed
|
||||
// _counter without calling setState(), then the build method would not be
|
||||
// called again, and so nothing would appear to happen.
|
||||
_counter++;
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
// This method is rerun every time setState is called, for instance as done
|
||||
// by the _incrementCounter method above.
|
||||
//
|
||||
// The Flutter framework has been optimized to make rerunning build methods
|
||||
// fast, so that you can just rebuild anything that needs updating rather
|
||||
// than having to individually change instances of widgets.
|
||||
return Scaffold(
|
||||
appBar: AppBar(
|
||||
// TRY THIS: Try changing the color here to a specific color (to
|
||||
// Colors.amber, perhaps?) and trigger a hot reload to see the AppBar
|
||||
// change color while the other colors stay the same.
|
||||
backgroundColor: Theme.of(context).colorScheme.inversePrimary,
|
||||
// Here we take the value from the MyHomePage object that was created by
|
||||
// the App.build method, and use it to set our appbar title.
|
||||
title: Text(widget.title),
|
||||
),
|
||||
body: Center(
|
||||
// Center is a layout widget. It takes a single child and positions it
|
||||
// in the middle of the parent.
|
||||
child: Column(
|
||||
// Column is also a layout widget. It takes a list of children and
|
||||
// arranges them vertically. By default, it sizes itself to fit its
|
||||
// children horizontally, and tries to be as tall as its parent.
|
||||
//
|
||||
// Column has various properties to control how it sizes itself and
|
||||
// how it positions its children. Here we use mainAxisAlignment to
|
||||
// center the children vertically; the main axis here is the vertical
|
||||
// axis because Columns are vertical (the cross axis would be
|
||||
// horizontal).
|
||||
//
|
||||
// TRY THIS: Invoke "debug painting" (choose the "Toggle Debug Paint"
|
||||
// action in the IDE, or press "p" in the console), to see the
|
||||
// wireframe for each widget.
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: <Widget>[
|
||||
const Text(
|
||||
'You have pushed the button this many times:',
|
||||
),
|
||||
Text(
|
||||
'$_counter',
|
||||
style: Theme.of(context).textTheme.headlineMedium,
|
||||
),
|
||||
],
|
||||
// Configure GoRouter
|
||||
final _router = GoRouter(
|
||||
initialLocation: '/',
|
||||
routes: [
|
||||
GoRoute(
|
||||
path: '/',
|
||||
builder: (context, state) => const HomePage(),
|
||||
routes: [
|
||||
GoRoute(
|
||||
path: 'settings',
|
||||
builder: (context, state) => const SettingsPage(),
|
||||
),
|
||||
),
|
||||
floatingActionButton: FloatingActionButton(
|
||||
onPressed: _incrementCounter,
|
||||
tooltip: 'Increment',
|
||||
child: const Icon(Icons.add),
|
||||
), // This trailing comma makes auto-formatting nicer for build methods.
|
||||
);
|
||||
}
|
||||
}
|
||||
GoRoute(
|
||||
path: 'connect_device',
|
||||
builder: (context, state) => const ConnectDevicePage(),
|
||||
)
|
||||
],
|
||||
),
|
||||
],
|
||||
);
|
||||
|
47
lib/model/bluetooth_device_model.dart
Normal file
47
lib/model/bluetooth_device_model.dart
Normal file
@ -0,0 +1,47 @@
|
||||
import 'package:freezed_annotation/freezed_annotation.dart';
|
||||
|
||||
part 'bluetooth_device_model.freezed.dart';
|
||||
part 'bluetooth_device_model.g.dart';
|
||||
|
||||
/// Enum representing the type of Bluetooth device
|
||||
enum DeviceType {
|
||||
/// Universal Shifters device
|
||||
universalShifters,
|
||||
|
||||
/// Other unspecified device types
|
||||
other,
|
||||
}
|
||||
|
||||
/// Model representing a Bluetooth device
|
||||
@freezed
|
||||
abstract class BluetoothDeviceModel with _$BluetoothDeviceModel {
|
||||
const factory BluetoothDeviceModel({
|
||||
/// Unique identifier for the device
|
||||
required String id,
|
||||
|
||||
/// Name of the device as advertised
|
||||
String? name,
|
||||
|
||||
/// MAC address of the device
|
||||
required String address,
|
||||
|
||||
/// Signal strength indicator (RSSI)
|
||||
int? rssi,
|
||||
|
||||
/// Type of the device
|
||||
@Default(DeviceType.other) DeviceType type,
|
||||
|
||||
/// Whether the device is currently connected
|
||||
@Default(false) bool isConnected,
|
||||
|
||||
/// Additional device information
|
||||
Map<String, dynamic>? manufacturerData,
|
||||
|
||||
/// Service UUIDs advertised by the device
|
||||
List<String>? serviceUuids,
|
||||
}) = _BluetoothDeviceModel;
|
||||
|
||||
/// Create a BluetoothDeviceModel from JSON
|
||||
factory BluetoothDeviceModel.fromJson(Map<String, dynamic> json) =>
|
||||
_$BluetoothDeviceModelFromJson(json);
|
||||
}
|
366
lib/model/bluetooth_device_model.freezed.dart
Normal file
366
lib/model/bluetooth_device_model.freezed.dart
Normal file
@ -0,0 +1,366 @@
|
||||
// dart format width=80
|
||||
// coverage:ignore-file
|
||||
// GENERATED CODE - DO NOT MODIFY BY HAND
|
||||
// ignore_for_file: type=lint
|
||||
// ignore_for_file: unused_element, deprecated_member_use, deprecated_member_use_from_same_package, use_function_type_syntax_for_parameters, unnecessary_const, avoid_init_to_null, invalid_override_different_default_values_named, prefer_expression_function_bodies, annotate_overrides, invalid_annotation_target, unnecessary_question_mark
|
||||
|
||||
part of 'bluetooth_device_model.dart';
|
||||
|
||||
// **************************************************************************
|
||||
// FreezedGenerator
|
||||
// **************************************************************************
|
||||
|
||||
// dart format off
|
||||
T _$identity<T>(T value) => value;
|
||||
|
||||
/// @nodoc
|
||||
mixin _$BluetoothDeviceModel {
|
||||
/// Unique identifier for the device
|
||||
String get id;
|
||||
|
||||
/// Name of the device as advertised
|
||||
String? get name;
|
||||
|
||||
/// MAC address of the device
|
||||
String get address;
|
||||
|
||||
/// Signal strength indicator (RSSI)
|
||||
int? get rssi;
|
||||
|
||||
/// Type of the device
|
||||
DeviceType get type;
|
||||
|
||||
/// Whether the device is currently connected
|
||||
bool get isConnected;
|
||||
|
||||
/// Additional device information
|
||||
Map<String, dynamic>? get manufacturerData;
|
||||
|
||||
/// Service UUIDs advertised by the device
|
||||
List<String>? get serviceUuids;
|
||||
|
||||
/// Create a copy of BluetoothDeviceModel
|
||||
/// with the given fields replaced by the non-null parameter values.
|
||||
@JsonKey(includeFromJson: false, includeToJson: false)
|
||||
@pragma('vm:prefer-inline')
|
||||
$BluetoothDeviceModelCopyWith<BluetoothDeviceModel> get copyWith =>
|
||||
_$BluetoothDeviceModelCopyWithImpl<BluetoothDeviceModel>(
|
||||
this as BluetoothDeviceModel, _$identity);
|
||||
|
||||
/// Serializes this BluetoothDeviceModel to a JSON map.
|
||||
Map<String, dynamic> toJson();
|
||||
|
||||
@override
|
||||
bool operator ==(Object other) {
|
||||
return identical(this, other) ||
|
||||
(other.runtimeType == runtimeType &&
|
||||
other is BluetoothDeviceModel &&
|
||||
(identical(other.id, id) || other.id == id) &&
|
||||
(identical(other.name, name) || other.name == name) &&
|
||||
(identical(other.address, address) || other.address == address) &&
|
||||
(identical(other.rssi, rssi) || other.rssi == rssi) &&
|
||||
(identical(other.type, type) || other.type == type) &&
|
||||
(identical(other.isConnected, isConnected) ||
|
||||
other.isConnected == isConnected) &&
|
||||
const DeepCollectionEquality()
|
||||
.equals(other.manufacturerData, manufacturerData) &&
|
||||
const DeepCollectionEquality()
|
||||
.equals(other.serviceUuids, serviceUuids));
|
||||
}
|
||||
|
||||
@JsonKey(includeFromJson: false, includeToJson: false)
|
||||
@override
|
||||
int get hashCode => Object.hash(
|
||||
runtimeType,
|
||||
id,
|
||||
name,
|
||||
address,
|
||||
rssi,
|
||||
type,
|
||||
isConnected,
|
||||
const DeepCollectionEquality().hash(manufacturerData),
|
||||
const DeepCollectionEquality().hash(serviceUuids));
|
||||
|
||||
@override
|
||||
String toString() {
|
||||
return 'BluetoothDeviceModel(id: $id, name: $name, address: $address, rssi: $rssi, type: $type, isConnected: $isConnected, manufacturerData: $manufacturerData, serviceUuids: $serviceUuids)';
|
||||
}
|
||||
}
|
||||
|
||||
/// @nodoc
|
||||
abstract mixin class $BluetoothDeviceModelCopyWith<$Res> {
|
||||
factory $BluetoothDeviceModelCopyWith(BluetoothDeviceModel value,
|
||||
$Res Function(BluetoothDeviceModel) _then) =
|
||||
_$BluetoothDeviceModelCopyWithImpl;
|
||||
@useResult
|
||||
$Res call(
|
||||
{String id,
|
||||
String? name,
|
||||
String address,
|
||||
int? rssi,
|
||||
DeviceType type,
|
||||
bool isConnected,
|
||||
Map<String, dynamic>? manufacturerData,
|
||||
List<String>? serviceUuids});
|
||||
}
|
||||
|
||||
/// @nodoc
|
||||
class _$BluetoothDeviceModelCopyWithImpl<$Res>
|
||||
implements $BluetoothDeviceModelCopyWith<$Res> {
|
||||
_$BluetoothDeviceModelCopyWithImpl(this._self, this._then);
|
||||
|
||||
final BluetoothDeviceModel _self;
|
||||
final $Res Function(BluetoothDeviceModel) _then;
|
||||
|
||||
/// Create a copy of BluetoothDeviceModel
|
||||
/// with the given fields replaced by the non-null parameter values.
|
||||
@pragma('vm:prefer-inline')
|
||||
@override
|
||||
$Res call({
|
||||
Object? id = null,
|
||||
Object? name = freezed,
|
||||
Object? address = null,
|
||||
Object? rssi = freezed,
|
||||
Object? type = null,
|
||||
Object? isConnected = null,
|
||||
Object? manufacturerData = freezed,
|
||||
Object? serviceUuids = freezed,
|
||||
}) {
|
||||
return _then(_self.copyWith(
|
||||
id: null == id
|
||||
? _self.id
|
||||
: id // ignore: cast_nullable_to_non_nullable
|
||||
as String,
|
||||
name: freezed == name
|
||||
? _self.name
|
||||
: name // ignore: cast_nullable_to_non_nullable
|
||||
as String?,
|
||||
address: null == address
|
||||
? _self.address
|
||||
: address // ignore: cast_nullable_to_non_nullable
|
||||
as String,
|
||||
rssi: freezed == rssi
|
||||
? _self.rssi
|
||||
: rssi // ignore: cast_nullable_to_non_nullable
|
||||
as int?,
|
||||
type: null == type
|
||||
? _self.type
|
||||
: type // ignore: cast_nullable_to_non_nullable
|
||||
as DeviceType,
|
||||
isConnected: null == isConnected
|
||||
? _self.isConnected
|
||||
: isConnected // ignore: cast_nullable_to_non_nullable
|
||||
as bool,
|
||||
manufacturerData: freezed == manufacturerData
|
||||
? _self.manufacturerData
|
||||
: manufacturerData // ignore: cast_nullable_to_non_nullable
|
||||
as Map<String, dynamic>?,
|
||||
serviceUuids: freezed == serviceUuids
|
||||
? _self.serviceUuids
|
||||
: serviceUuids // ignore: cast_nullable_to_non_nullable
|
||||
as List<String>?,
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
/// @nodoc
|
||||
@JsonSerializable()
|
||||
class _BluetoothDeviceModel implements BluetoothDeviceModel {
|
||||
const _BluetoothDeviceModel(
|
||||
{required this.id,
|
||||
this.name,
|
||||
required this.address,
|
||||
this.rssi,
|
||||
this.type = DeviceType.other,
|
||||
this.isConnected = false,
|
||||
final Map<String, dynamic>? manufacturerData,
|
||||
final List<String>? serviceUuids})
|
||||
: _manufacturerData = manufacturerData,
|
||||
_serviceUuids = serviceUuids;
|
||||
factory _BluetoothDeviceModel.fromJson(Map<String, dynamic> json) =>
|
||||
_$BluetoothDeviceModelFromJson(json);
|
||||
|
||||
/// Unique identifier for the device
|
||||
@override
|
||||
final String id;
|
||||
|
||||
/// Name of the device as advertised
|
||||
@override
|
||||
final String? name;
|
||||
|
||||
/// MAC address of the device
|
||||
@override
|
||||
final String address;
|
||||
|
||||
/// Signal strength indicator (RSSI)
|
||||
@override
|
||||
final int? rssi;
|
||||
|
||||
/// Type of the device
|
||||
@override
|
||||
@JsonKey()
|
||||
final DeviceType type;
|
||||
|
||||
/// Whether the device is currently connected
|
||||
@override
|
||||
@JsonKey()
|
||||
final bool isConnected;
|
||||
|
||||
/// Additional device information
|
||||
final Map<String, dynamic>? _manufacturerData;
|
||||
|
||||
/// Additional device information
|
||||
@override
|
||||
Map<String, dynamic>? get manufacturerData {
|
||||
final value = _manufacturerData;
|
||||
if (value == null) return null;
|
||||
if (_manufacturerData is EqualUnmodifiableMapView) return _manufacturerData;
|
||||
// ignore: implicit_dynamic_type
|
||||
return EqualUnmodifiableMapView(value);
|
||||
}
|
||||
|
||||
/// Service UUIDs advertised by the device
|
||||
final List<String>? _serviceUuids;
|
||||
|
||||
/// Service UUIDs advertised by the device
|
||||
@override
|
||||
List<String>? get serviceUuids {
|
||||
final value = _serviceUuids;
|
||||
if (value == null) return null;
|
||||
if (_serviceUuids is EqualUnmodifiableListView) return _serviceUuids;
|
||||
// ignore: implicit_dynamic_type
|
||||
return EqualUnmodifiableListView(value);
|
||||
}
|
||||
|
||||
/// Create a copy of BluetoothDeviceModel
|
||||
/// with the given fields replaced by the non-null parameter values.
|
||||
@override
|
||||
@JsonKey(includeFromJson: false, includeToJson: false)
|
||||
@pragma('vm:prefer-inline')
|
||||
_$BluetoothDeviceModelCopyWith<_BluetoothDeviceModel> get copyWith =>
|
||||
__$BluetoothDeviceModelCopyWithImpl<_BluetoothDeviceModel>(
|
||||
this, _$identity);
|
||||
|
||||
@override
|
||||
Map<String, dynamic> toJson() {
|
||||
return _$BluetoothDeviceModelToJson(
|
||||
this,
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
bool operator ==(Object other) {
|
||||
return identical(this, other) ||
|
||||
(other.runtimeType == runtimeType &&
|
||||
other is _BluetoothDeviceModel &&
|
||||
(identical(other.id, id) || other.id == id) &&
|
||||
(identical(other.name, name) || other.name == name) &&
|
||||
(identical(other.address, address) || other.address == address) &&
|
||||
(identical(other.rssi, rssi) || other.rssi == rssi) &&
|
||||
(identical(other.type, type) || other.type == type) &&
|
||||
(identical(other.isConnected, isConnected) ||
|
||||
other.isConnected == isConnected) &&
|
||||
const DeepCollectionEquality()
|
||||
.equals(other._manufacturerData, _manufacturerData) &&
|
||||
const DeepCollectionEquality()
|
||||
.equals(other._serviceUuids, _serviceUuids));
|
||||
}
|
||||
|
||||
@JsonKey(includeFromJson: false, includeToJson: false)
|
||||
@override
|
||||
int get hashCode => Object.hash(
|
||||
runtimeType,
|
||||
id,
|
||||
name,
|
||||
address,
|
||||
rssi,
|
||||
type,
|
||||
isConnected,
|
||||
const DeepCollectionEquality().hash(_manufacturerData),
|
||||
const DeepCollectionEquality().hash(_serviceUuids));
|
||||
|
||||
@override
|
||||
String toString() {
|
||||
return 'BluetoothDeviceModel(id: $id, name: $name, address: $address, rssi: $rssi, type: $type, isConnected: $isConnected, manufacturerData: $manufacturerData, serviceUuids: $serviceUuids)';
|
||||
}
|
||||
}
|
||||
|
||||
/// @nodoc
|
||||
abstract mixin class _$BluetoothDeviceModelCopyWith<$Res>
|
||||
implements $BluetoothDeviceModelCopyWith<$Res> {
|
||||
factory _$BluetoothDeviceModelCopyWith(_BluetoothDeviceModel value,
|
||||
$Res Function(_BluetoothDeviceModel) _then) =
|
||||
__$BluetoothDeviceModelCopyWithImpl;
|
||||
@override
|
||||
@useResult
|
||||
$Res call(
|
||||
{String id,
|
||||
String? name,
|
||||
String address,
|
||||
int? rssi,
|
||||
DeviceType type,
|
||||
bool isConnected,
|
||||
Map<String, dynamic>? manufacturerData,
|
||||
List<String>? serviceUuids});
|
||||
}
|
||||
|
||||
/// @nodoc
|
||||
class __$BluetoothDeviceModelCopyWithImpl<$Res>
|
||||
implements _$BluetoothDeviceModelCopyWith<$Res> {
|
||||
__$BluetoothDeviceModelCopyWithImpl(this._self, this._then);
|
||||
|
||||
final _BluetoothDeviceModel _self;
|
||||
final $Res Function(_BluetoothDeviceModel) _then;
|
||||
|
||||
/// Create a copy of BluetoothDeviceModel
|
||||
/// with the given fields replaced by the non-null parameter values.
|
||||
@override
|
||||
@pragma('vm:prefer-inline')
|
||||
$Res call({
|
||||
Object? id = null,
|
||||
Object? name = freezed,
|
||||
Object? address = null,
|
||||
Object? rssi = freezed,
|
||||
Object? type = null,
|
||||
Object? isConnected = null,
|
||||
Object? manufacturerData = freezed,
|
||||
Object? serviceUuids = freezed,
|
||||
}) {
|
||||
return _then(_BluetoothDeviceModel(
|
||||
id: null == id
|
||||
? _self.id
|
||||
: id // ignore: cast_nullable_to_non_nullable
|
||||
as String,
|
||||
name: freezed == name
|
||||
? _self.name
|
||||
: name // ignore: cast_nullable_to_non_nullable
|
||||
as String?,
|
||||
address: null == address
|
||||
? _self.address
|
||||
: address // ignore: cast_nullable_to_non_nullable
|
||||
as String,
|
||||
rssi: freezed == rssi
|
||||
? _self.rssi
|
||||
: rssi // ignore: cast_nullable_to_non_nullable
|
||||
as int?,
|
||||
type: null == type
|
||||
? _self.type
|
||||
: type // ignore: cast_nullable_to_non_nullable
|
||||
as DeviceType,
|
||||
isConnected: null == isConnected
|
||||
? _self.isConnected
|
||||
: isConnected // ignore: cast_nullable_to_non_nullable
|
||||
as bool,
|
||||
manufacturerData: freezed == manufacturerData
|
||||
? _self._manufacturerData
|
||||
: manufacturerData // ignore: cast_nullable_to_non_nullable
|
||||
as Map<String, dynamic>?,
|
||||
serviceUuids: freezed == serviceUuids
|
||||
? _self._serviceUuids
|
||||
: serviceUuids // ignore: cast_nullable_to_non_nullable
|
||||
as List<String>?,
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
// dart format on
|
41
lib/model/bluetooth_device_model.g.dart
Normal file
41
lib/model/bluetooth_device_model.g.dart
Normal file
@ -0,0 +1,41 @@
|
||||
// GENERATED CODE - DO NOT MODIFY BY HAND
|
||||
|
||||
part of 'bluetooth_device_model.dart';
|
||||
|
||||
// **************************************************************************
|
||||
// JsonSerializableGenerator
|
||||
// **************************************************************************
|
||||
|
||||
_BluetoothDeviceModel _$BluetoothDeviceModelFromJson(
|
||||
Map<String, dynamic> json) =>
|
||||
_BluetoothDeviceModel(
|
||||
id: json['id'] as String,
|
||||
name: json['name'] as String?,
|
||||
address: json['address'] as String,
|
||||
rssi: (json['rssi'] as num?)?.toInt(),
|
||||
type: $enumDecodeNullable(_$DeviceTypeEnumMap, json['type']) ??
|
||||
DeviceType.other,
|
||||
isConnected: json['isConnected'] as bool? ?? false,
|
||||
manufacturerData: json['manufacturerData'] as Map<String, dynamic>?,
|
||||
serviceUuids: (json['serviceUuids'] as List<dynamic>?)
|
||||
?.map((e) => e as String)
|
||||
.toList(),
|
||||
);
|
||||
|
||||
Map<String, dynamic> _$BluetoothDeviceModelToJson(
|
||||
_BluetoothDeviceModel instance) =>
|
||||
<String, dynamic>{
|
||||
'id': instance.id,
|
||||
'name': instance.name,
|
||||
'address': instance.address,
|
||||
'rssi': instance.rssi,
|
||||
'type': _$DeviceTypeEnumMap[instance.type]!,
|
||||
'isConnected': instance.isConnected,
|
||||
'manufacturerData': instance.manufacturerData,
|
||||
'serviceUuids': instance.serviceUuids,
|
||||
};
|
||||
|
||||
const _$DeviceTypeEnumMap = {
|
||||
DeviceType.universalShifters: 'universalShifters',
|
||||
DeviceType.other: 'other',
|
||||
};
|
288
lib/pages/devices_page.dart
Normal file
288
lib/pages/devices_page.dart
Normal file
@ -0,0 +1,288 @@
|
||||
import 'package:abawo_bt_app/controller/bluetooth.dart';
|
||||
import 'package:abawo_bt_app/util/constants.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
import 'package:flutter_blue_plus/flutter_blue_plus.dart';
|
||||
import 'package:abawo_bt_app/widgets/device_listitem.dart';
|
||||
import 'package:abawo_bt_app/widgets/scanning_animation.dart'; // Import the new animation widget
|
||||
|
||||
const Duration _scanDuration = Duration(seconds: 10);
|
||||
|
||||
class ConnectDevicePage extends ConsumerStatefulWidget {
|
||||
const ConnectDevicePage({super.key});
|
||||
|
||||
@override
|
||||
ConsumerState<ConnectDevicePage> createState() => _ConnectDevicePageState();
|
||||
}
|
||||
|
||||
class _ConnectDevicePageState extends ConsumerState<ConnectDevicePage>
|
||||
with TickerProviderStateMixin {
|
||||
// Use TickerProviderStateMixin for multiple controllers if needed later, good practice
|
||||
bool _initialScanStarted = false;
|
||||
bool _showOnlyAbawoDevices = true; // State for filtering devices
|
||||
late AnimationController
|
||||
_progressController; // Controller for scan duration progress
|
||||
late AnimationController
|
||||
_waveAnimationController; // Controller for wave animation
|
||||
|
||||
// Function to start scan safely after controller is ready
|
||||
void _startScanIfNeeded(BluetoothController controller) {
|
||||
// Use WidgetsBinding to schedule the scan start after the build phase
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
if (!_initialScanStarted && mounted) {
|
||||
controller.startScan(timeout: _scanDuration);
|
||||
_startScanProgressAnimation(); // Start scan duration progress animation
|
||||
_startWaveAnimation(); // Start the wave animation
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
_initialScanStarted = true;
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
// Initialize scan progress controller
|
||||
_progressController = AnimationController(
|
||||
vsync: this,
|
||||
duration: _scanDuration,
|
||||
)..addListener(() {
|
||||
// Trigger rebuild when animation value changes for the progress indicator
|
||||
if (mounted) {
|
||||
setState(() {});
|
||||
}
|
||||
});
|
||||
|
||||
// Initialize wave animation controller
|
||||
_waveAnimationController = AnimationController(
|
||||
vsync: this,
|
||||
duration: const Duration(milliseconds: 1500), // New duration: 1.5 seconds
|
||||
)..repeat(); // Make the wave animation repeat
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
// Stop animations before disposing
|
||||
_progressController.stop();
|
||||
_waveAnimationController.stop();
|
||||
_progressController.dispose();
|
||||
_waveAnimationController.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
// Helper method to start/reset scan progress animation
|
||||
void _startScanProgressAnimation() {
|
||||
if (mounted) {
|
||||
_progressController.reset();
|
||||
_progressController.forward().whenCompleteOrCancel(() {
|
||||
// Optional: Add logic if needed when scan progress completes or cancels
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Helper method to start the wave animation
|
||||
void _startWaveAnimation() {
|
||||
if (mounted && !_waveAnimationController.isAnimating) {
|
||||
_waveAnimationController.reset();
|
||||
_waveAnimationController.repeat();
|
||||
}
|
||||
}
|
||||
|
||||
// Helper method to stop animations when scan finishes/cancels
|
||||
void _stopAnimations() {
|
||||
if (mounted) {
|
||||
if (_progressController.isAnimating) {
|
||||
_progressController.stop();
|
||||
}
|
||||
if (_waveAnimationController.isAnimating) {
|
||||
_waveAnimationController.stop();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Scaffold(
|
||||
appBar: AppBar(
|
||||
title: const Text('Connect Device'),
|
||||
leading: IconButton(
|
||||
icon: const Icon(Icons.arrow_back),
|
||||
onPressed: () => context.go('/'),
|
||||
),
|
||||
actions: [
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(right: 12.0),
|
||||
child: Row(
|
||||
children: [
|
||||
const Text('abawo only'), // Label for the switch
|
||||
Switch(
|
||||
value: _showOnlyAbawoDevices,
|
||||
onChanged: (value) {
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
_showOnlyAbawoDevices = value;
|
||||
});
|
||||
}
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
)
|
||||
],
|
||||
),
|
||||
body: Center(
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
const Text(
|
||||
'Available Devices',
|
||||
style: TextStyle(fontSize: 20, fontWeight: FontWeight.bold),
|
||||
),
|
||||
const SizedBox(height: 20),
|
||||
// Use Consumer to react to bluetoothProvider changes
|
||||
Expanded(
|
||||
// Allow the Consumer content to expand
|
||||
child: Consumer(builder: (context, ref, child) {
|
||||
final btAsyncValue = ref.watch(bluetoothProvider);
|
||||
|
||||
return btAsyncValue.when(
|
||||
loading: () => const Center(
|
||||
child:
|
||||
CircularProgressIndicator()), // Center loading indicator
|
||||
error: (err, stack) => Center(
|
||||
child: Text(
|
||||
'Error loading Bluetooth: $err')), // Center error
|
||||
data: (controller) {
|
||||
// Start the initial scan once the controller is ready
|
||||
// Start initial scan and animation
|
||||
_startScanIfNeeded(controller);
|
||||
|
||||
// Use StreamBuilder to watch the scanning state
|
||||
return StreamBuilder<bool>(
|
||||
stream: FlutterBluePlus.isScanning,
|
||||
initialData:
|
||||
false, // Default to not scanning before check
|
||||
builder: (context, snapshot) {
|
||||
final isScanning = snapshot.data ?? false;
|
||||
|
||||
if (isScanning && _initialScanStarted) {
|
||||
_startWaveAnimation(); // Ensure wave animation is running
|
||||
// Show the new scanning wave animation with the progress indicator value
|
||||
return Center(
|
||||
// Pass the wave animation controller. The progress value will be added
|
||||
// to the ScanningWaveAnimation widget itself later.
|
||||
child: ScanningWaveAnimation(
|
||||
animation: _waveAnimationController,
|
||||
progressValue: _progressController
|
||||
.value, // Pass the scan progress
|
||||
),
|
||||
);
|
||||
} else if (!_initialScanStarted) {
|
||||
// Show placeholder or button to start initial scan if needed, or just empty space
|
||||
return const SizedBox(
|
||||
height: 50); // Placeholder before scan starts
|
||||
} else {
|
||||
// Scan finished, stop animations and show results
|
||||
_stopAnimations();
|
||||
final results = controller.scanResults;
|
||||
// Filter results based on the toggle state
|
||||
final filteredResults = _showOnlyAbawoDevices
|
||||
? results
|
||||
.where((device) => device
|
||||
.advertisementData.serviceUuids
|
||||
.contains(Guid(abawoServiceBtUUID)))
|
||||
.toList()
|
||||
: results;
|
||||
|
||||
// Use Column + Expanded for ListView + Button layout
|
||||
return Column(
|
||||
children: [
|
||||
Expanded(
|
||||
// Allow ListView to take available space
|
||||
child: filteredResults
|
||||
.isEmpty // Use filtered list check
|
||||
? const Center(
|
||||
child: Text(
|
||||
'No devices found.')) // Center empty text
|
||||
: ListView.builder(
|
||||
itemCount: filteredResults
|
||||
.length, // Use filtered list length
|
||||
itemBuilder: (context, index) {
|
||||
final device = filteredResults[
|
||||
index]; // Use filtered list
|
||||
final isAbawoDevice = device
|
||||
.advertisementData.serviceUuids
|
||||
.contains(
|
||||
Guid(abawoServiceBtUUID));
|
||||
final deviceName =
|
||||
device.device.advName.isEmpty
|
||||
? 'Unknown Device'
|
||||
: device.device.advName;
|
||||
// Use the custom DeviceListItem widget
|
||||
return InkWell(
|
||||
// Wrap with InkWell for tap feedback
|
||||
onTap: () {
|
||||
if (!isAbawoDevice) {
|
||||
// Show a snackbar for non-Abawo devices
|
||||
ScaffoldMessenger.of(context)
|
||||
.showSnackBar(const SnackBar(
|
||||
content: Text(
|
||||
'This app can only connect to abawo devices.')));
|
||||
return;
|
||||
}
|
||||
// TODO: Implement connect logic
|
||||
// controller.connectToDevice(device.device); // Pass the BluetoothDevice
|
||||
// context.go('/control/${device.device.remoteId.str}');
|
||||
print(
|
||||
'Tapped on ${device.device.remoteId.str}');
|
||||
},
|
||||
child: DeviceListItem(
|
||||
deviceName: deviceName,
|
||||
deviceId:
|
||||
device.device.remoteId.str,
|
||||
isUnknownDevice:
|
||||
device.device.advName.isEmpty,
|
||||
),
|
||||
);
|
||||
} // End of itemBuilder
|
||||
),
|
||||
),
|
||||
Padding(
|
||||
// Add padding around the button
|
||||
padding: const EdgeInsets.all(16.0),
|
||||
child: ElevatedButton(
|
||||
onPressed: () {
|
||||
// Retry scan by calling startScan on the controller
|
||||
// Ensure _initialScanStarted is true so indicator shows
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
_initialScanStarted = true;
|
||||
});
|
||||
}
|
||||
controller.startScan(
|
||||
timeout: _scanDuration);
|
||||
_startScanProgressAnimation(); // Restart scan progress animation
|
||||
_startWaveAnimation(); // Ensure wave animation runs on retry
|
||||
},
|
||||
child: const Text('Retry Scan'),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
},
|
||||
);
|
||||
},
|
||||
);
|
||||
}),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
75
lib/pages/home_page.dart
Normal file
75
lib/pages/home_page.dart
Normal file
@ -0,0 +1,75 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
|
||||
class HomePage extends StatelessWidget {
|
||||
const HomePage({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Scaffold(
|
||||
appBar: AppBar(
|
||||
title: const Text('Abawo BT App'),
|
||||
actions: [
|
||||
IconButton(
|
||||
icon: const Icon(Icons.settings),
|
||||
onPressed: () => context.go('/settings'),
|
||||
),
|
||||
],
|
||||
),
|
||||
body: Center(
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
const Text(
|
||||
'Welcome to Abawo BT App',
|
||||
style: TextStyle(fontSize: 20),
|
||||
),
|
||||
const SizedBox(height: 20),
|
||||
// Devices Section
|
||||
Container(
|
||||
padding: const EdgeInsets.only(left: 16, right: 16),
|
||||
child: Column(
|
||||
children: [
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
const Text(
|
||||
'Devices',
|
||||
style: TextStyle(
|
||||
fontSize: 20, fontWeight: FontWeight.w500),
|
||||
),
|
||||
IconButton(
|
||||
icon: const Icon(Icons.add_circle),
|
||||
onPressed: () => context.go('/connect_device'),
|
||||
tooltip: 'Connect a device',
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 10),
|
||||
Container(
|
||||
padding: const EdgeInsets.all(16),
|
||||
decoration: BoxDecoration(
|
||||
border: Border.all(color: Colors.grey.shade300),
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
child: const Center(
|
||||
child: Text(
|
||||
'No devices connected yet',
|
||||
style: TextStyle(color: Colors.grey),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 30),
|
||||
ElevatedButton(
|
||||
onPressed: () => context.go('/settings'),
|
||||
child: const Text('Go to Settings'),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
50
lib/pages/settings_page.dart
Normal file
50
lib/pages/settings_page.dart
Normal file
@ -0,0 +1,50 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
|
||||
class SettingsPage extends StatelessWidget {
|
||||
const SettingsPage({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Scaffold(
|
||||
appBar: AppBar(
|
||||
title: const Text('Settings'),
|
||||
leading: IconButton(
|
||||
icon: const Icon(Icons.arrow_back),
|
||||
onPressed: () => context.go('/'),
|
||||
),
|
||||
),
|
||||
body: ListView(
|
||||
children: [
|
||||
ListTile(
|
||||
leading: const Icon(Icons.brightness_6),
|
||||
title: const Text('Theme'),
|
||||
subtitle: const Text('Change app theme'),
|
||||
trailing: const Icon(Icons.chevron_right),
|
||||
onTap: () {
|
||||
// Theme settings functionality
|
||||
},
|
||||
),
|
||||
ListTile(
|
||||
leading: const Icon(Icons.bluetooth),
|
||||
title: const Text('Bluetooth Settings'),
|
||||
subtitle: const Text('Configure Bluetooth connections'),
|
||||
trailing: const Icon(Icons.chevron_right),
|
||||
onTap: () {
|
||||
// Bluetooth settings functionality
|
||||
},
|
||||
),
|
||||
ListTile(
|
||||
leading: const Icon(Icons.info),
|
||||
title: const Text('About'),
|
||||
subtitle: const Text('App information'),
|
||||
trailing: const Icon(Icons.chevron_right),
|
||||
onTap: () {
|
||||
// About screen functionality
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
1
lib/util/constants.dart
Normal file
1
lib/util/constants.dart
Normal file
@ -0,0 +1 @@
|
||||
const abawoServiceBtUUID = '0993826f-0ee4-4b37-9614-d13ecba4ffc2';
|
73
lib/util/sharedPrefs.dart
Normal file
73
lib/util/sharedPrefs.dart
Normal file
@ -0,0 +1,73 @@
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:riverpod_annotation/riverpod_annotation.dart';
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
|
||||
part 'sharedPrefs.g.dart';
|
||||
|
||||
final sharedPreferencesProvider =
|
||||
Provider<SharedPreferences>((ref) => throw UnimplementedError());
|
||||
|
||||
class SharedPrefNotifier<T> extends StateNotifier<T> {
|
||||
final SharedPreferences prefs;
|
||||
final String key;
|
||||
final T defaultValue;
|
||||
|
||||
SharedPrefNotifier({
|
||||
required this.prefs,
|
||||
required this.key,
|
||||
required this.defaultValue,
|
||||
}) : super(prefs.containsKey(key)
|
||||
? _getValue<T>(prefs, key, defaultValue)
|
||||
: defaultValue);
|
||||
|
||||
// Helper to get the value with proper typing
|
||||
static T _getValue<T>(SharedPreferences prefs, String key, T defaultValue) {
|
||||
switch (T) {
|
||||
case String:
|
||||
return prefs.getString(key) as T ?? defaultValue;
|
||||
case bool:
|
||||
return prefs.getBool(key) as T ?? defaultValue;
|
||||
case int:
|
||||
return prefs.getInt(key) as T ?? defaultValue;
|
||||
case double:
|
||||
return prefs.getDouble(key) as T ?? defaultValue;
|
||||
case const (List<String>):
|
||||
return prefs.getStringList(key) as T ?? defaultValue;
|
||||
default:
|
||||
throw UnsupportedError('Type $T not supported by SharedPreferences');
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> update(T value) async {
|
||||
switch (T) {
|
||||
case String:
|
||||
await prefs.setString(key, value as String);
|
||||
break;
|
||||
case bool:
|
||||
await prefs.setBool(key, value as bool);
|
||||
break;
|
||||
case int:
|
||||
await prefs.setInt(key, value as int);
|
||||
break;
|
||||
case double:
|
||||
await prefs.setDouble(key, value as double);
|
||||
break;
|
||||
case const (List<String>):
|
||||
await prefs.setStringList(key, value as List<String>);
|
||||
break;
|
||||
default:
|
||||
throw UnsupportedError('Type $T not supported by SharedPreferences');
|
||||
}
|
||||
state = value;
|
||||
}
|
||||
}
|
||||
|
||||
final isDarkModeProvider =
|
||||
StateNotifierProvider<SharedPrefNotifier<bool>, bool>((ref) {
|
||||
final prefs = ref.watch(sharedPreferencesProvider);
|
||||
return SharedPrefNotifier<bool>(
|
||||
prefs: prefs,
|
||||
key: 'is_dark_mode',
|
||||
defaultValue: false,
|
||||
);
|
||||
});
|
197
lib/util/sharedPrefs.g.dart
Normal file
197
lib/util/sharedPrefs.g.dart
Normal file
@ -0,0 +1,197 @@
|
||||
// GENERATED CODE - DO NOT MODIFY BY HAND
|
||||
|
||||
part of 'sharedPrefs.dart';
|
||||
|
||||
// **************************************************************************
|
||||
// RiverpodGenerator
|
||||
// **************************************************************************
|
||||
|
||||
String _$sharedPrefValueHash() => r'6c78fac8d11d0df162d4d53f465c1c8535fcd150';
|
||||
|
||||
/// Copied from Dart SDK
|
||||
class _SystemHash {
|
||||
_SystemHash._();
|
||||
|
||||
static int combine(int hash, int value) {
|
||||
// ignore: parameter_assignments
|
||||
hash = 0x1fffffff & (hash + value);
|
||||
// ignore: parameter_assignments
|
||||
hash = 0x1fffffff & (hash + ((0x0007ffff & hash) << 10));
|
||||
return hash ^ (hash >> 6);
|
||||
}
|
||||
|
||||
static int finish(int hash) {
|
||||
// ignore: parameter_assignments
|
||||
hash = 0x1fffffff & (hash + ((0x03ffffff & hash) << 3));
|
||||
// ignore: parameter_assignments
|
||||
hash = hash ^ (hash >> 11);
|
||||
return 0x1fffffff & (hash + ((0x00003fff & hash) << 15));
|
||||
}
|
||||
}
|
||||
|
||||
abstract class _$SharedPrefValue extends BuildlessAutoDisposeNotifier<T> {
|
||||
late final String key;
|
||||
late final T defaultValue;
|
||||
|
||||
T build(
|
||||
String key,
|
||||
T defaultValue,
|
||||
);
|
||||
}
|
||||
|
||||
/// See also [SharedPrefValue].
|
||||
@ProviderFor(SharedPrefValue)
|
||||
const sharedPrefValueProvider = SharedPrefValueFamily();
|
||||
|
||||
/// See also [SharedPrefValue].
|
||||
class SharedPrefValueFamily extends Family<T> {
|
||||
/// See also [SharedPrefValue].
|
||||
const SharedPrefValueFamily();
|
||||
|
||||
/// See also [SharedPrefValue].
|
||||
SharedPrefValueProvider call(
|
||||
String key,
|
||||
T defaultValue,
|
||||
) {
|
||||
return SharedPrefValueProvider(
|
||||
key,
|
||||
defaultValue,
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
SharedPrefValueProvider getProviderOverride(
|
||||
covariant SharedPrefValueProvider provider,
|
||||
) {
|
||||
return call(
|
||||
provider.key,
|
||||
provider.defaultValue,
|
||||
);
|
||||
}
|
||||
|
||||
static const Iterable<ProviderOrFamily>? _dependencies = null;
|
||||
|
||||
@override
|
||||
Iterable<ProviderOrFamily>? get dependencies => _dependencies;
|
||||
|
||||
static const Iterable<ProviderOrFamily>? _allTransitiveDependencies = null;
|
||||
|
||||
@override
|
||||
Iterable<ProviderOrFamily>? get allTransitiveDependencies =>
|
||||
_allTransitiveDependencies;
|
||||
|
||||
@override
|
||||
String? get name => r'sharedPrefValueProvider';
|
||||
}
|
||||
|
||||
/// See also [SharedPrefValue].
|
||||
class SharedPrefValueProvider
|
||||
extends AutoDisposeNotifierProviderImpl<SharedPrefValue, T> {
|
||||
/// See also [SharedPrefValue].
|
||||
SharedPrefValueProvider(
|
||||
String key,
|
||||
T defaultValue,
|
||||
) : this._internal(
|
||||
() => SharedPrefValue()
|
||||
..key = key
|
||||
..defaultValue = defaultValue,
|
||||
from: sharedPrefValueProvider,
|
||||
name: r'sharedPrefValueProvider',
|
||||
debugGetCreateSourceHash:
|
||||
const bool.fromEnvironment('dart.vm.product')
|
||||
? null
|
||||
: _$sharedPrefValueHash,
|
||||
dependencies: SharedPrefValueFamily._dependencies,
|
||||
allTransitiveDependencies:
|
||||
SharedPrefValueFamily._allTransitiveDependencies,
|
||||
key: key,
|
||||
defaultValue: defaultValue,
|
||||
);
|
||||
|
||||
SharedPrefValueProvider._internal(
|
||||
super._createNotifier, {
|
||||
required super.name,
|
||||
required super.dependencies,
|
||||
required super.allTransitiveDependencies,
|
||||
required super.debugGetCreateSourceHash,
|
||||
required super.from,
|
||||
required this.key,
|
||||
required this.defaultValue,
|
||||
}) : super.internal();
|
||||
|
||||
final String key;
|
||||
final T defaultValue;
|
||||
|
||||
@override
|
||||
T runNotifierBuild(
|
||||
covariant SharedPrefValue notifier,
|
||||
) {
|
||||
return notifier.build(
|
||||
key,
|
||||
defaultValue,
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Override overrideWith(SharedPrefValue Function() create) {
|
||||
return ProviderOverride(
|
||||
origin: this,
|
||||
override: SharedPrefValueProvider._internal(
|
||||
() => create()
|
||||
..key = key
|
||||
..defaultValue = defaultValue,
|
||||
from: from,
|
||||
name: null,
|
||||
dependencies: null,
|
||||
allTransitiveDependencies: null,
|
||||
debugGetCreateSourceHash: null,
|
||||
key: key,
|
||||
defaultValue: defaultValue,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
AutoDisposeNotifierProviderElement<SharedPrefValue, T> createElement() {
|
||||
return _SharedPrefValueProviderElement(this);
|
||||
}
|
||||
|
||||
@override
|
||||
bool operator ==(Object other) {
|
||||
return other is SharedPrefValueProvider &&
|
||||
other.key == key &&
|
||||
other.defaultValue == defaultValue;
|
||||
}
|
||||
|
||||
@override
|
||||
int get hashCode {
|
||||
var hash = _SystemHash.combine(0, runtimeType.hashCode);
|
||||
hash = _SystemHash.combine(hash, key.hashCode);
|
||||
hash = _SystemHash.combine(hash, defaultValue.hashCode);
|
||||
|
||||
return _SystemHash.finish(hash);
|
||||
}
|
||||
}
|
||||
|
||||
@Deprecated('Will be removed in 3.0. Use Ref instead')
|
||||
// ignore: unused_element
|
||||
mixin SharedPrefValueRef on AutoDisposeNotifierProviderRef<T> {
|
||||
/// The parameter `key` of this provider.
|
||||
String get key;
|
||||
|
||||
/// The parameter `defaultValue` of this provider.
|
||||
T get defaultValue;
|
||||
}
|
||||
|
||||
class _SharedPrefValueProviderElement
|
||||
extends AutoDisposeNotifierProviderElement<SharedPrefValue, T>
|
||||
with SharedPrefValueRef {
|
||||
_SharedPrefValueProviderElement(super.provider);
|
||||
|
||||
@override
|
||||
String get key => (origin as SharedPrefValueProvider).key;
|
||||
@override
|
||||
T get defaultValue => (origin as SharedPrefValueProvider).defaultValue;
|
||||
}
|
||||
// ignore_for_file: type=lint
|
||||
// ignore_for_file: subtype_of_sealed_class, invalid_use_of_internal_member, invalid_use_of_visible_for_testing_member, deprecated_member_use_from_same_package
|
117
lib/widgets/device_listitem.dart
Normal file
117
lib/widgets/device_listitem.dart
Normal file
@ -0,0 +1,117 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'dart:ui'; // Required for ImageFilter
|
||||
|
||||
class DeviceListItem extends StatelessWidget {
|
||||
final String deviceName;
|
||||
final String deviceId; // Added for potential future use or subtitle
|
||||
final bool isUnknownDevice;
|
||||
// final String? imageUrl; // Optional image URL - commented out for now
|
||||
|
||||
const DeviceListItem({
|
||||
super.key,
|
||||
required this.deviceName,
|
||||
required this.deviceId,
|
||||
this.isUnknownDevice = false,
|
||||
// this.imageUrl,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
final isDarkMode = theme.brightness == Brightness.dark;
|
||||
|
||||
// Glassy effect colors - adjust transparency and base color as needed
|
||||
final glassColor = isDarkMode
|
||||
? Colors.white.withOpacity(0.1)
|
||||
: Colors.black.withOpacity(0.05);
|
||||
final shadowColor = isDarkMode
|
||||
? Colors.black.withOpacity(0.4)
|
||||
: Colors.grey.withOpacity(0.5);
|
||||
|
||||
return Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16.0, vertical: 8.0),
|
||||
child: Row(
|
||||
children: [
|
||||
// Left side: Rounded Square Container
|
||||
Container(
|
||||
width: 60, // Square size
|
||||
height: 60,
|
||||
decoration: BoxDecoration(
|
||||
borderRadius: BorderRadius.circular(12), // Rounded corners
|
||||
boxShadow: [
|
||||
BoxShadow(
|
||||
color: shadowColor,
|
||||
blurRadius: 10,
|
||||
offset: const Offset(0, 4),
|
||||
),
|
||||
],
|
||||
),
|
||||
child: ClipRRect(
|
||||
// Apply blur effect within rounded corners
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
child: BackdropFilter(
|
||||
filter: ImageFilter.blur(sigmaX: 5, sigmaY: 5),
|
||||
child: Container(
|
||||
decoration: BoxDecoration(
|
||||
color:
|
||||
glassColor, // Semi-transparent color for glass effect
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
border: Border.all(
|
||||
color: Colors.white.withOpacity(0.2), // Subtle border
|
||||
width: 0.5,
|
||||
),
|
||||
),
|
||||
child: const Center(
|
||||
// Placeholder '?' - replace with Image widget when imageUrl is available
|
||||
child: Text(
|
||||
'?',
|
||||
style: TextStyle(
|
||||
fontSize: 30,
|
||||
fontWeight: FontWeight.bold,
|
||||
color: Colors.white70, // Adjust color as needed
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 16), // Spacing between image and text
|
||||
|
||||
// Right side: Device Name and ID
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
isUnknownDevice ? 'Unknown Device' : deviceName,
|
||||
style: TextStyle(
|
||||
fontSize: 16,
|
||||
fontWeight:
|
||||
isUnknownDevice ? FontWeight.normal : FontWeight.w500,
|
||||
fontStyle:
|
||||
isUnknownDevice ? FontStyle.italic : FontStyle.normal,
|
||||
color: isUnknownDevice
|
||||
? theme.hintColor
|
||||
: theme.textTheme.bodyLarge?.color,
|
||||
),
|
||||
overflow: TextOverflow
|
||||
.ellipsis, // Prevent long names from overflowing
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
Text(
|
||||
deviceId, // Display device ID as subtitle
|
||||
style: theme.textTheme.bodySmall
|
||||
?.copyWith(color: theme.hintColor),
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
// Optional: Add an icon or button on the far right if needed later
|
||||
// Icon(Icons.chevron_right, color: theme.hintColor),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
187
lib/widgets/scanning_animation.dart
Normal file
187
lib/widgets/scanning_animation.dart
Normal file
@ -0,0 +1,187 @@
|
||||
import 'dart:math';
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
class ScanningWaveAnimation extends StatefulWidget {
|
||||
final Animation<double>
|
||||
animation; // For the wave effect (0.0 to 1.0 over 1.5s)
|
||||
final double
|
||||
progressValue; // For the CircularProgressIndicator (0.0 to 1.0 over scan duration)
|
||||
final Color waveColor; // Color for the scanning wave
|
||||
|
||||
const ScanningWaveAnimation({
|
||||
super.key,
|
||||
required this.animation,
|
||||
required this.progressValue,
|
||||
this.waveColor = Colors.lightBlueAccent, // Default color using constant
|
||||
});
|
||||
|
||||
@override
|
||||
_ScanningWaveAnimationState createState() => _ScanningWaveAnimationState();
|
||||
}
|
||||
|
||||
class _ScanningWaveAnimationState extends State<ScanningWaveAnimation> {
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
// Stack to layer the painter, progress indicator, and text
|
||||
return Stack(
|
||||
alignment: Alignment.center,
|
||||
children: [
|
||||
// The wave animation painter rebuilds based on the wave animation
|
||||
AnimatedBuilder(
|
||||
animation: widget.animation,
|
||||
builder: (context, child) {
|
||||
return CustomPaint(
|
||||
// Use full available size for the painter
|
||||
size: Size.infinite,
|
||||
painter: _WavePainter(
|
||||
progress: widget.animation.value, // Pass wave progress
|
||||
waveColor: widget.waveColor), // Pass wave color
|
||||
);
|
||||
},
|
||||
),
|
||||
// The progress indicator and text in the center
|
||||
Column(
|
||||
mainAxisSize: MainAxisSize.min, // Keep column compact
|
||||
children: [
|
||||
CircularProgressIndicator(
|
||||
value: widget.progressValue, // Use the scan duration progress
|
||||
backgroundColor:
|
||||
Colors.white.withValues(alpha: 0.1), // Subtle background
|
||||
),
|
||||
const SizedBox(height: 16), // Consistent spacing
|
||||
const Text(
|
||||
'Scanning for devices...',
|
||||
style: TextStyle(
|
||||
fontSize: 16, color: Colors.white70), // Lighter text
|
||||
),
|
||||
], // Close children[] of Column
|
||||
), // Close Column
|
||||
], // Close children[] of Stack
|
||||
); // Close Stack
|
||||
}
|
||||
}
|
||||
|
||||
// --- New Wave Painter Implementation ---
|
||||
class _WavePainter extends CustomPainter {
|
||||
final double
|
||||
progress; // Animation value from 0.0 to 1.0, drives the single wave
|
||||
final Color waveColor; // The color of the wave
|
||||
final double startRadius = 50.0; // Start wave ~175px from center
|
||||
final double waveThickness = 10.0; // Thickness of the wave
|
||||
final double waveExpansion = 50.0; // Amount of thickness increase at the end
|
||||
|
||||
_WavePainter({required this.progress, required this.waveColor});
|
||||
|
||||
@override
|
||||
void paint(Canvas canvas, Size size) {
|
||||
final center = Offset(size.width / 2, size.height / 2);
|
||||
// Max radius should reach significantly beyond the screen edge
|
||||
final maxRadius = min(size.width, size.height) * 0.8; // Extend further
|
||||
// Ensure endRadius is always larger than startRadius
|
||||
final endRadius = max(startRadius + 1.0, maxRadius);
|
||||
|
||||
// Apply deceleration curve to the progress
|
||||
final easedProgress = Curves.easeOut.transform(progress);
|
||||
|
||||
final realWaveThickness = waveThickness + waveExpansion * easedProgress;
|
||||
|
||||
// --- Opacity Calculation (Fade-in / Fade-out) ---
|
||||
const double fadeInEnd = 0.20; // Faster fade-in (0% to 20% of animation)
|
||||
const double fadeOutStart = 0.45; // Start fade-out earlier (45% to 100%)
|
||||
|
||||
final double fadeInOpacity =
|
||||
(progress < fadeInEnd) ? progress / fadeInEnd : 1.0;
|
||||
|
||||
final double fadeOutProgress = (progress < fadeOutStart)
|
||||
? 0.0 // Not fading out yet
|
||||
: (progress - fadeOutStart) /
|
||||
(1.0 - fadeOutStart); // Map fade-out phase to 0.0-1.0
|
||||
final double fadeOutOpacity =
|
||||
max(0.0, 1.0 - fadeOutProgress); // Linear fade-out
|
||||
|
||||
// Combine fade-in and fade-out using minimum
|
||||
final opacity = max(0.0, min(fadeInOpacity, fadeOutOpacity));
|
||||
|
||||
// Skip drawing if fully faded out
|
||||
if (opacity <= 0.001) {
|
||||
// Use a small threshold to avoid floating point issues
|
||||
return;
|
||||
}
|
||||
|
||||
// --- Base Radius Calculation ---
|
||||
// Calculate the base radius for this frame based on decelerated progress
|
||||
final baseRadius = startRadius + (endRadius - startRadius) * easedProgress;
|
||||
|
||||
// Skip drawing if radius hasn't reached startRadius yet
|
||||
// (Needed because easedProgress might be 0 at the very start)
|
||||
if (baseRadius < startRadius) {
|
||||
return;
|
||||
}
|
||||
|
||||
// --- Path Generation with Deformation ---
|
||||
final path = Path();
|
||||
final int steps = 200; // Increase steps for smoother deformation
|
||||
final double angleStep = (2 * pi) / steps;
|
||||
|
||||
// Noise parameters (tune these for desired 'waviness' and 'shimmer')
|
||||
final double noiseMaxAmplitude =
|
||||
realWaveThickness * 0.4; // Max radius deviation
|
||||
final double noiseAmplitude = noiseMaxAmplitude *
|
||||
min(easedProgress * 4.0,
|
||||
1.0 - easedProgress * 0.8); // Amplitude decreases as it expands
|
||||
final double noiseFreq1 = 6.0; // Controls number of 'bumps'
|
||||
final double noiseFreq2 = 12.0; // Another layer of bumps
|
||||
final double phaseShift = progress * pi * 6; // Controls 'shimmer' speed
|
||||
|
||||
for (int i = 0; i <= steps; i++) {
|
||||
final double angle = i * angleStep;
|
||||
|
||||
// Calculate noise perturbation using layered sine waves
|
||||
// Different frequencies and phase shifts create complex patterns
|
||||
double perturbation = noiseAmplitude *
|
||||
(0.6 * sin(noiseFreq1 * angle + phaseShift) +
|
||||
0.4 *
|
||||
sin(noiseFreq2 * angle -
|
||||
phaseShift * 0.6) // Opposite phase shift adds complexity
|
||||
);
|
||||
|
||||
final double currentRadius = max(
|
||||
0.0, baseRadius + perturbation); // Ensure radius doesn't go negative
|
||||
|
||||
// Convert polar (angle, radius) to cartesian (x, y)
|
||||
final double x = center.dx + currentRadius * cos(angle);
|
||||
final double y = center.dy + currentRadius * sin(angle);
|
||||
|
||||
if (i == 0) {
|
||||
path.moveTo(x, y); // Start the path
|
||||
} else {
|
||||
path.lineTo(x, y); // Draw line to next point
|
||||
}
|
||||
}
|
||||
path.close(); // Connect the last point back to the first
|
||||
|
||||
// --- Painting the Path ---
|
||||
// Use the provided wave color with calculated opacity
|
||||
final paintColor = waveColor.withValues(alpha: opacity * 0.75);
|
||||
|
||||
final paint = Paint()
|
||||
..color = paintColor
|
||||
..style = PaintingStyle.stroke
|
||||
..strokeWidth = realWaveThickness
|
||||
..strokeCap =
|
||||
StrokeCap.round // Soften line endings (though path is closed)
|
||||
..strokeJoin = StrokeJoin.round // Soften corners in the deformation
|
||||
// Apply blur for feathered/soft edges, sigma related to thickness
|
||||
..maskFilter = MaskFilter.blur(BlurStyle.normal, realWaveThickness * 0.5);
|
||||
|
||||
// Draw the deformed, blurred path
|
||||
canvas.drawPath(path, paint);
|
||||
}
|
||||
|
||||
@override
|
||||
bool shouldRepaint(covariant _WavePainter oldDelegate) {
|
||||
// Repaint whenever the animation progress changes
|
||||
return oldDelegate.progress != progress ||
|
||||
oldDelegate.waveColor != waveColor;
|
||||
}
|
||||
}
|
Reference in New Issue
Block a user