feat: everything up to bluetooth scanning

This commit is contained in:
2025-03-26 21:01:42 +01:00
parent be964fab25
commit f4022dd249
21 changed files with 2496 additions and 132 deletions

View 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);
}
}

View 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

View File

@ -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(),
)
],
),
],
);

View 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);
}

View 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

View 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
View 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
View 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'),
),
],
),
),
);
}
}

View 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
View File

@ -0,0 +1 @@
const abawoServiceBtUUID = '0993826f-0ee4-4b37-9614-d13ecba4ffc2';

73
lib/util/sharedPrefs.dart Normal file
View 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
View 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

View 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),
],
),
);
}
}

View 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;
}
}