feat(ui): add themed shell navigation

This commit is contained in:
2026-04-23 21:57:24 +02:00
parent bf67e9c2ae
commit 8cf6e95474
14 changed files with 531 additions and 122 deletions

View File

@ -9,6 +9,12 @@
# packages, and plugins designed to encourage good coding practices. # packages, and plugins designed to encourage good coding practices.
include: package:flutter_lints/flutter.yaml include: package:flutter_lints/flutter.yaml
analyzer:
errors:
invalid_annotation_target: ignore
plugins:
- custom_lint
linter: linter:
# The lint rules applied to this project can be customized in the # The lint rules applied to this project can be customized in the
# section below to disable rules from the `package:flutter_lints/flutter.yaml` # section below to disable rules from the `package:flutter_lints/flutter.yaml`
@ -23,12 +29,5 @@ linter:
rules: rules:
# avoid_print: false # Uncomment to disable the `avoid_print` rule # avoid_print: false # Uncomment to disable the `avoid_print` rule
# prefer_single_quotes: true # Uncomment to enable the `prefer_single_quotes` rule # prefer_single_quotes: true # Uncomment to enable the `prefer_single_quotes` rule
errors:
invalid_annotation_target: ignore
plugins:
- custom_lint
# Additional information about this file can be found at # Additional information about this file can be found at
# https://dart.dev/guides/language/analysis-options # https://dart.dev/guides/language/analysis-options

View File

@ -1,13 +1,27 @@
import 'package:abawo_bt_app/util/sharedPrefs.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:flutter_test/flutter_test.dart'; import 'package:flutter_test/flutter_test.dart';
import 'package:abawo_bt_app/main.dart'; import 'package:abawo_bt_app/main.dart';
import 'package:abawo_bt_app/src/rust/frb_generated.dart'; import 'package:abawo_bt_app/src/rust/frb_generated.dart';
import 'package:integration_test/integration_test.dart'; import 'package:integration_test/integration_test.dart';
import 'package:shared_preferences/shared_preferences.dart';
void main() { void main() {
IntegrationTestWidgetsFlutterBinding.ensureInitialized(); IntegrationTestWidgetsFlutterBinding.ensureInitialized();
setUpAll(() async => await RustLib.init()); setUpAll(() async => await RustLib.init());
testWidgets('Can call rust function', (WidgetTester tester) async {
await tester.pumpWidget(const MyApp()); testWidgets('App launches to devices tab', (WidgetTester tester) async {
expect(find.textContaining('Result: `Hello, Tom!`'), findsOneWidget); SharedPreferences.setMockInitialValues({});
final prefs = await SharedPreferences.getInstance();
await tester.pumpWidget(
ProviderScope(
overrides: [sharedPreferencesProvider.overrideWithValue(prefs)],
child: const AbawoBtApp(),
),
);
await tester.pumpAndSettle();
expect(find.text('Manage and connect your hardware.'), findsOneWidget);
}); });
} }

View File

@ -10,13 +10,13 @@ part 'database.g.dart';
class NConnectedDevices extends _$NConnectedDevices { class NConnectedDevices extends _$NConnectedDevices {
@override @override
Future<List<ConnectedDevice>> build() async { Future<List<ConnectedDevice>> build() async {
final db = await ref.watch(databaseProvider); final db = ref.watch(databaseProvider);
return await db.getAllConnectedDevices(); return db.getAllConnectedDevices();
} }
Future<Result<int>> addConnectedDevice( Future<Result<int>> addConnectedDevice(
ConnectedDevicesCompanion device) async { ConnectedDevicesCompanion device) async {
final db = await ref.watch(databaseProvider); final db = ref.watch(databaseProvider);
final res = await db.addConnectedDevice(device); final res = await db.addConnectedDevice(device);
if (res.isOk()) { if (res.isOk()) {
ref.invalidateSelf(); ref.invalidateSelf();
@ -25,7 +25,7 @@ class NConnectedDevices extends _$NConnectedDevices {
} }
Future<Result<void>> deleteConnectedDevice(int id) async { Future<Result<void>> deleteConnectedDevice(int id) async {
final db = await ref.watch(databaseProvider); final db = ref.watch(databaseProvider);
final res = await db.deleteConnectedDevice(id); final res = await db.deleteConnectedDevice(id);
if (res.isOk()) { if (res.isOk()) {
ref.invalidateSelf(); ref.invalidateSelf();

View File

@ -1,19 +1,21 @@
import 'package:abawo_bt_app/pages/devices_page.dart'; import 'package:abawo_bt_app/pages/devices_page.dart';
import 'package:abawo_bt_app/pages/devices_tab_page.dart';
import 'package:abawo_bt_app/src/rust/frb_generated.dart'; import 'package:abawo_bt_app/src/rust/frb_generated.dart';
import 'package:abawo_bt_app/theme/app_theme.dart';
import 'package:abawo_bt_app/util/sharedPrefs.dart'; import 'package:abawo_bt_app/util/sharedPrefs.dart';
import 'package:abawo_bt_app/widgets/app_shell.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:go_router/go_router.dart'; import 'package:go_router/go_router.dart';
import 'package:logging/logging.dart'; import 'package:logging/logging.dart';
import 'package:nb_utils/nb_utils.dart'; import 'package:nb_utils/nb_utils.dart';
import 'pages/home_page.dart';
import 'pages/settings_page.dart'; import 'pages/settings_page.dart';
import 'package:abawo_bt_app/pages/device_details_page.dart'; import 'package:abawo_bt_app/pages/device_details_page.dart';
Future<void> main() async { Future<void> main() async {
Logger.root.level = Level.ALL; // defaults to Level.INFO Logger.root.level = Level.ALL; // defaults to Level.INFO
Logger.root.onRecord.listen((record) { Logger.root.onRecord.listen((record) {
print('${record.level.name}: ${record.time}: ${record.message}'); debugPrint('${record.level.name}: ${record.time}: ${record.message}');
}); });
await RustLib.init(); await RustLib.init();
WidgetsFlutterBinding.ensureInitialized(); WidgetsFlutterBinding.ensureInitialized();
@ -27,24 +29,22 @@ Future<void> main() async {
], child: const AbawoBtApp())); ], child: const AbawoBtApp()));
} }
class AbawoBtApp extends StatelessWidget { class AbawoBtApp extends ConsumerWidget {
const AbawoBtApp({super.key}); const AbawoBtApp({super.key});
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context, WidgetRef ref) {
final themePreference = ref.watch(appThemePreferenceProvider);
return MaterialApp.router( return MaterialApp.router(
title: 'Abawo BT App', title: 'Abawo BT App',
theme: ThemeData( theme: AppTheme.light(),
primarySwatch: Colors.blue, darkTheme: AppTheme.dark(),
useMaterial3: true, themeMode: switch (themePreference) {
brightness: Brightness.light, AppThemePreference.light => ThemeMode.light,
), AppThemePreference.dark => ThemeMode.dark,
darkTheme: ThemeData( AppThemePreference.system => ThemeMode.system,
primarySwatch: Colors.blue, },
useMaterial3: true,
brightness: Brightness.dark,
),
themeMode: ThemeMode.system,
routerConfig: _router, routerConfig: _router,
debugShowCheckedModeBanner: false, debugShowCheckedModeBanner: false,
); );
@ -54,22 +54,28 @@ class AbawoBtApp extends StatelessWidget {
// Configure GoRouter // Configure GoRouter
final _router = GoRouter( final _router = GoRouter(
navigatorKey: navigatorKey, navigatorKey: navigatorKey,
initialLocation: '/', initialLocation: '/devices',
routes: [
ShellRoute(
builder: (context, state, child) => AppShell(
currentLocation: state.uri.path,
child: child,
),
routes: [ routes: [
GoRoute( GoRoute(
path: '/', path: '/devices',
builder: (context, state) => const HomePage(), builder: (context, state) => const DevicesTabPage(),
routes: [
GoRoute(
path: 'settings',
builder: (context, state) => const SettingsPage(),
), ),
GoRoute( GoRoute(
path: 'connect_device', path: '/settings',
builder: (context, state) => const ConnectDevicePage(), builder: (context, state) => const SettingsPage(),
) ),
], ],
), ),
GoRoute(
path: '/connect_device',
builder: (context, state) => const ConnectDevicePage(),
),
GoRoute( GoRoute(
path: '/device/:deviceAddress', path: '/device/:deviceAddress',
builder: (context, state) { builder: (context, state) {

View File

@ -587,7 +587,7 @@ class _DeviceDetailsPageState extends ConsumerState<DeviceDetailsPage> {
} }
toast(toastMessage); toast(toastMessage);
context.replace('/'); context.replace('/devices');
} }
Future<void> _cancelReconnect() async { Future<void> _cancelReconnect() async {
@ -599,7 +599,7 @@ class _DeviceDetailsPageState extends ConsumerState<DeviceDetailsPage> {
if (!mounted) { if (!mounted) {
return; return;
} }
context.replace('/'); context.replace('/devices');
} }
void _showStatusHistory() { void _showStatusHistory() {

View File

@ -65,7 +65,7 @@ class _ConnectDevicePageState extends ConsumerState<ConnectDevicePage>
title: const Text('Connect Device'), title: const Text('Connect Device'),
leading: IconButton( leading: IconButton(
icon: const Icon(Icons.arrow_back), icon: const Icon(Icons.arrow_back),
onPressed: () => context.go('/'), onPressed: () => context.go('/devices'),
), ),
actions: [ actions: [
Padding( Padding(
@ -188,7 +188,10 @@ class _ConnectDevicePageState extends ConsumerState<ConnectDevicePage>
return; return;
} else { } else {
final res = await controller.connect(device); final res = await controller.connect(device);
print('res: $res'); if (!mounted) {
return;
}
switch (res) { switch (res) {
case Ok(): case Ok():
// trigger pairing/permission prompt if needed // trigger pairing/permission prompt if needed
@ -231,6 +234,9 @@ class _ConnectDevicePageState extends ConsumerState<ConnectDevicePage>
} }
break; break;
case Err(:final v): case Err(:final v):
if (!context.mounted) {
break;
}
ScaffoldMessenger.of(context) ScaffoldMessenger.of(context)
.showSnackBar(SnackBar( .showSnackBar(SnackBar(
content: Text( content: Text(
@ -239,7 +245,6 @@ class _ConnectDevicePageState extends ConsumerState<ConnectDevicePage>
break; break;
} }
} }
print('Tapped on ${device.id}');
}, },
child: DeviceListItem( child: DeviceListItem(
deviceName: deviceName, deviceName: deviceName,

View File

@ -0,0 +1,67 @@
import 'package:abawo_bt_app/pages/home_page.dart';
import 'package:flutter/material.dart';
import 'package:go_router/go_router.dart';
class DevicesTabPage extends StatelessWidget {
const DevicesTabPage({super.key});
@override
Widget build(BuildContext context) {
return ListView(
padding: const EdgeInsets.fromLTRB(20, 16, 20, 24),
children: [
Row(
children: [
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'Devices',
style: Theme.of(context).textTheme.headlineMedium?.copyWith(
fontWeight: FontWeight.w700,
),
),
const SizedBox(height: 6),
Text(
'Manage and connect your hardware.',
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
color: Theme.of(context)
.colorScheme
.onSurface
.withValues(alpha: 0.68),
),
),
],
),
),
IconButton.filledTonal(
tooltip: 'Connect a device',
onPressed: () => context.go('/connect_device'),
icon: const Icon(Icons.add),
),
],
),
const SizedBox(height: 20),
Card(
child: Padding(
padding: const EdgeInsets.fromLTRB(16, 16, 16, 8),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'Saved devices',
style: Theme.of(context).textTheme.titleMedium?.copyWith(
fontWeight: FontWeight.w700,
),
),
const SizedBox(height: 8),
const DevicesList(),
],
),
),
),
],
);
}
}

View File

@ -181,6 +181,10 @@ class _DevicesListState extends ConsumerState<DevicesList> {
timeout: const Duration(seconds: 10), timeout: const Duration(seconds: 10),
); );
if (!context.mounted) {
return;
}
if (result.isOk()) { if (result.isOk()) {
context.go('/device/${device.deviceAddress}'); context.go('/device/${device.deviceAddress}');
} else { } else {
@ -193,14 +197,19 @@ class _DevicesListState extends ConsumerState<DevicesList> {
); );
} }
} catch (e) { } catch (e) {
if (!context.mounted) {
return;
}
ScaffoldMessenger.of(context).showSnackBar( ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('Error: ${e.toString()}')), SnackBar(content: Text('Error: ${e.toString()}')),
); );
} finally { } finally {
if (context.mounted) {
setState(() { setState(() {
_connectingDeviceId = null; _connectingDeviceId = null;
}); });
} }
}
}, },
child: DeviceListItem( child: DeviceListItem(
deviceName: device.deviceName, deviceName: device.deviceName,

View File

@ -1,50 +1,54 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:go_router/go_router.dart';
class SettingsPage extends StatelessWidget { class SettingsPage extends StatelessWidget {
const SettingsPage({super.key}); const SettingsPage({super.key});
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return Scaffold( return ListView(
appBar: AppBar( padding: const EdgeInsets.fromLTRB(20, 16, 20, 24),
title: const Text('Settings'),
leading: IconButton(
icon: const Icon(Icons.arrow_back),
onPressed: () => context.go('/'),
),
),
body: ListView(
children: [ children: [
ListTile( Text(
leading: const Icon(Icons.brightness_6), 'Settings',
title: const Text('Theme'), style: Theme.of(context).textTheme.headlineMedium?.copyWith(
subtitle: const Text('Change app theme'), fontWeight: FontWeight.w700,
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
},
), ),
const SizedBox(height: 6),
Text(
'Theme, Bluetooth, and app details.',
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
color: Theme.of(context)
.colorScheme
.onSurface
.withValues(alpha: 0.68),
),
),
const SizedBox(height: 20),
Card(
child: Column(
children: const [
ListTile( ListTile(
leading: const Icon(Icons.info), leading: Icon(Icons.brightness_6),
title: const Text('About'), title: Text('Theme'),
subtitle: const Text('App information'), subtitle: Text('Theme controls arrive in the next phase'),
trailing: const Icon(Icons.chevron_right), ),
onTap: () { Divider(height: 1),
// About screen functionality ListTile(
}, leading: Icon(Icons.bluetooth),
title: Text('Bluetooth Settings'),
subtitle: Text('Configure Bluetooth connections'),
),
Divider(height: 1),
ListTile(
leading: Icon(Icons.info),
title: Text('About'),
subtitle: Text('App information'),
), ),
], ],
), ),
),
],
); );
} }
} }

184
lib/theme/app_theme.dart Normal file
View File

@ -0,0 +1,184 @@
import 'package:flutter/material.dart';
class AppTheme {
static const Color brandBlue = Color(0xFF2F7BFF);
static const Color darkBackground = Color(0xFF071A2E);
static const Color darkSurface = Color(0xFF10253A);
static const Color darkSurfaceAlt = Color(0xFF173149);
static const Color lightBackground = Color(0xFFF4F8FC);
static const Color lightSurface = Color(0xFFFFFFFF);
static const Color lightSurfaceAlt = Color(0xFFEAF1F8);
static ThemeData light() {
return _buildTheme(
brightness: Brightness.light,
colorScheme: ColorScheme.fromSeed(
seedColor: brandBlue,
brightness: Brightness.light,
primary: brandBlue,
surface: lightSurface,
),
scaffoldBackgroundColor: lightBackground,
panelColor: lightSurface,
panelAltColor: lightSurfaceAlt,
);
}
static ThemeData dark() {
return _buildTheme(
brightness: Brightness.dark,
colorScheme: ColorScheme.fromSeed(
seedColor: brandBlue,
brightness: Brightness.dark,
primary: brandBlue,
surface: darkSurface,
),
scaffoldBackgroundColor: darkBackground,
panelColor: darkSurface,
panelAltColor: darkSurfaceAlt,
);
}
static ThemeData _buildTheme({
required Brightness brightness,
required ColorScheme colorScheme,
required Color scaffoldBackgroundColor,
required Color panelColor,
required Color panelAltColor,
}) {
final isDark = brightness == Brightness.dark;
final onSurfaceMuted = colorScheme.onSurface.withValues(alpha: 0.68);
final baseChipTheme = ThemeData(
useMaterial3: true,
brightness: brightness,
colorScheme: colorScheme,
).chipTheme;
return ThemeData(
useMaterial3: true,
brightness: brightness,
colorScheme: colorScheme,
scaffoldBackgroundColor: scaffoldBackgroundColor,
dividerColor: colorScheme.outlineVariant.withValues(alpha: 0.42),
appBarTheme: AppBarTheme(
elevation: 0,
scrolledUnderElevation: 0,
backgroundColor: Colors.transparent,
foregroundColor: colorScheme.onSurface,
centerTitle: false,
titleTextStyle: TextStyle(
color: colorScheme.onSurface,
fontSize: 28,
fontWeight: FontWeight.w700,
letterSpacing: -0.5,
),
),
cardTheme: CardThemeData(
elevation: 0,
color: panelColor,
margin: EdgeInsets.zero,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(22),
side: BorderSide(
color: colorScheme.outlineVariant.withValues(alpha: 0.55),
),
),
),
navigationBarTheme: NavigationBarThemeData(
backgroundColor: panelColor,
indicatorColor: colorScheme.primary.withValues(alpha: isDark ? 0.20 : 0.14),
labelTextStyle: WidgetStateProperty.resolveWith(
(states) => TextStyle(
fontSize: 12,
fontWeight: states.contains(WidgetState.selected)
? FontWeight.w700
: FontWeight.w500,
),
),
),
filledButtonTheme: FilledButtonThemeData(
style: FilledButton.styleFrom(
backgroundColor: colorScheme.primary,
foregroundColor: colorScheme.onPrimary,
minimumSize: const Size.fromHeight(52),
padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 14),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(16),
),
textStyle: const TextStyle(fontSize: 16, fontWeight: FontWeight.w700),
),
),
outlinedButtonTheme: OutlinedButtonThemeData(
style: OutlinedButton.styleFrom(
foregroundColor: colorScheme.onSurface,
minimumSize: const Size.fromHeight(48),
side: BorderSide(
color: colorScheme.outlineVariant.withValues(alpha: 0.8),
),
padding: const EdgeInsets.symmetric(horizontal: 18, vertical: 14),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(16),
),
),
),
textButtonTheme: TextButtonThemeData(
style: TextButton.styleFrom(
foregroundColor: colorScheme.primary,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(14),
),
),
),
listTileTheme: ListTileThemeData(
iconColor: colorScheme.primary,
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(18)),
contentPadding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
),
chipTheme: baseChipTheme.copyWith(
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(999),
),
side: BorderSide(
color: colorScheme.outlineVariant.withValues(alpha: 0.6),
),
),
inputDecorationTheme: InputDecorationTheme(
filled: true,
fillColor: panelAltColor,
contentPadding: const EdgeInsets.symmetric(horizontal: 16, vertical: 14),
hintStyle: TextStyle(color: onSurfaceMuted),
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(16),
borderSide: BorderSide.none,
),
enabledBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(16),
borderSide: BorderSide(
color: colorScheme.outlineVariant.withValues(alpha: 0.55),
),
),
focusedBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(16),
borderSide: BorderSide(color: colorScheme.primary, width: 1.2),
),
),
dialogTheme: DialogThemeData(
backgroundColor: panelColor,
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(24)),
),
bottomSheetTheme: BottomSheetThemeData(
backgroundColor: panelColor,
modalBackgroundColor: panelColor,
shape: const RoundedRectangleBorder(
borderRadius: BorderRadius.vertical(top: Radius.circular(28)),
),
),
snackBarTheme: SnackBarThemeData(
behavior: SnackBarBehavior.floating,
backgroundColor: panelAltColor,
contentTextStyle: TextStyle(color: colorScheme.onSurface),
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)),
),
);
}
}

View File

@ -1,3 +1,5 @@
// ignore_for_file: file_names
import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:shared_preferences/shared_preferences.dart'; import 'package:shared_preferences/shared_preferences.dart';
@ -21,43 +23,61 @@ class SharedPrefNotifier<T> extends StateNotifier<T> {
// Helper to get the value with proper typing // Helper to get the value with proper typing
static T _getValue<T>(SharedPreferences prefs, String key, T defaultValue) { static T _getValue<T>(SharedPreferences prefs, String key, T defaultValue) {
switch (T) { if (T == String) {
case String: return prefs.getString(key) as T? ?? defaultValue;
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');
} }
if (T == bool) {
return prefs.getBool(key) as T? ?? defaultValue;
}
if (T == int) {
return prefs.getInt(key) as T? ?? defaultValue;
}
if (T == double) {
return prefs.getDouble(key) as T? ?? defaultValue;
}
if (T == List<String>) {
return prefs.getStringList(key) as T? ?? defaultValue;
}
throw UnsupportedError('Type $T not supported by SharedPreferences');
} }
Future<void> update(T value) async { Future<void> update(T value) async {
switch (T) { if (T == String) {
case String:
await prefs.setString(key, value as 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; state = value;
return;
}
if (T == bool) {
await prefs.setBool(key, value as bool);
state = value;
return;
}
if (T == int) {
await prefs.setInt(key, value as int);
state = value;
return;
}
if (T == double) {
await prefs.setDouble(key, value as double);
state = value;
return;
}
if (T == List<String>) {
await prefs.setStringList(key, value as List<String>);
state = value;
return;
}
throw UnsupportedError('Type $T not supported by SharedPreferences');
} }
} }
@ -70,3 +90,37 @@ final isDarkModeProvider =
defaultValue: false, defaultValue: false,
); );
}); });
enum AppThemePreference {
system,
light,
dark,
}
class ThemePreferenceNotifier extends StateNotifier<AppThemePreference> {
ThemePreferenceNotifier(this._prefs)
: super(_themePreferenceFromStorage(_prefs.getString(_themeModeKey)));
static const String _themeModeKey = 'app_theme_preference';
final SharedPreferences _prefs;
Future<void> update(AppThemePreference value) async {
await _prefs.setString(_themeModeKey, value.name);
state = value;
}
}
AppThemePreference _themePreferenceFromStorage(String? value) {
return switch (value) {
'light' => AppThemePreference.light,
'dark' => AppThemePreference.dark,
_ => AppThemePreference.system,
};
}
final appThemePreferenceProvider =
StateNotifierProvider<ThemePreferenceNotifier, AppThemePreference>((ref) {
final prefs = ref.watch(sharedPreferencesProvider);
return ThemePreferenceNotifier(prefs);
});

View File

@ -0,0 +1,68 @@
import 'package:flutter/material.dart';
import 'package:go_router/go_router.dart';
class AppShell extends StatelessWidget {
const AppShell({
required this.child,
required this.currentLocation,
super.key,
});
final Widget child;
final String currentLocation;
@override
Widget build(BuildContext context) {
final selectedIndex = currentLocation.startsWith('/settings') ? 1 : 0;
return Scaffold(
body: DecoratedBox(
decoration: BoxDecoration(
gradient: LinearGradient(
begin: Alignment.topCenter,
end: Alignment.bottomCenter,
colors: [
Theme.of(context).scaffoldBackgroundColor,
Theme.of(context).colorScheme.surface,
],
),
),
child: SafeArea(bottom: false, child: child),
),
bottomNavigationBar: SafeArea(
top: false,
child: Padding(
padding: const EdgeInsets.fromLTRB(16, 0, 16, 16),
child: ClipRRect(
borderRadius: BorderRadius.circular(24),
child: NavigationBar(
selectedIndex: selectedIndex,
onDestinationSelected: (index) {
switch (index) {
case 0:
context.go('/devices');
break;
case 1:
context.go('/settings');
break;
}
},
destinations: const [
NavigationDestination(
icon: Icon(Icons.bluetooth_searching_outlined),
selectedIcon: Icon(Icons.bluetooth_searching),
label: 'Devices',
),
NavigationDestination(
icon: Icon(Icons.settings_outlined),
selectedIcon: Icon(Icons.settings),
label: 'Settings',
),
],
),
),
),
),
);
}
}

View File

@ -12,7 +12,7 @@ class HorizontalScanningAnimation extends StatefulWidget {
this.height = 50.0, this.height = 50.0,
}); });
@override @override
_HorizontalScanningAnimationState createState() => State<HorizontalScanningAnimation> createState() =>
_HorizontalScanningAnimationState(); _HorizontalScanningAnimationState();
} }
@ -117,8 +117,6 @@ class _HorizontalWavePainter extends CustomPainter {
double currentWidth = double currentWidth =
width * easedProgress * 0.8; // Max 80% width expansion width * easedProgress * 0.8; // Max 80% width expansion
double startX = (width / 2) - (currentWidth / 2); double startX = (width / 2) - (currentWidth / 2);
double endX = (width / 2) + (currentWidth / 2);
// Calculate opacity based on progress (fade in and out) // Calculate opacity based on progress (fade in and out)
double opacity; double opacity;
if (waveProgress < 0.1) { if (waveProgress < 0.1) {
@ -130,8 +128,9 @@ class _HorizontalWavePainter extends CustomPainter {
} }
opacity = max(0.0, opacity); // Clamp opacity opacity = max(0.0, opacity); // Clamp opacity
if (opacity <= 0.0 || currentWidth < 5) if (opacity <= 0.0 || currentWidth < 5) {
continue; // Skip drawing if invisible or too small continue;
}
// Create the wave path // Create the wave path
final path = Path(); final path = Path();

View File

@ -16,7 +16,7 @@ class ScanningWaveAnimation extends StatefulWidget {
}); });
@override @override
_ScanningWaveAnimationState createState() => _ScanningWaveAnimationState(); State<ScanningWaveAnimation> createState() => _ScanningWaveAnimationState();
} }
class _ScanningWaveAnimationState extends State<ScanningWaveAnimation> { class _ScanningWaveAnimationState extends State<ScanningWaveAnimation> {