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

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

View File

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

View File

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

View File

@ -65,7 +65,7 @@ class _ConnectDevicePageState extends ConsumerState<ConnectDevicePage>
title: const Text('Connect Device'),
leading: IconButton(
icon: const Icon(Icons.arrow_back),
onPressed: () => context.go('/'),
onPressed: () => context.go('/devices'),
),
actions: [
Padding(
@ -188,7 +188,10 @@ class _ConnectDevicePageState extends ConsumerState<ConnectDevicePage>
return;
} else {
final res = await controller.connect(device);
print('res: $res');
if (!mounted) {
return;
}
switch (res) {
case Ok():
// trigger pairing/permission prompt if needed
@ -231,6 +234,9 @@ class _ConnectDevicePageState extends ConsumerState<ConnectDevicePage>
}
break;
case Err(:final v):
if (!context.mounted) {
break;
}
ScaffoldMessenger.of(context)
.showSnackBar(SnackBar(
content: Text(
@ -239,7 +245,6 @@ class _ConnectDevicePageState extends ConsumerState<ConnectDevicePage>
break;
}
}
print('Tapped on ${device.id}');
},
child: DeviceListItem(
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),
);
if (!context.mounted) {
return;
}
if (result.isOk()) {
context.go('/device/${device.deviceAddress}');
} else {
@ -193,13 +197,18 @@ class _DevicesListState extends ConsumerState<DevicesList> {
);
}
} catch (e) {
if (!context.mounted) {
return;
}
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('Error: ${e.toString()}')),
);
} finally {
setState(() {
_connectingDeviceId = null;
});
if (context.mounted) {
setState(() {
_connectingDeviceId = null;
});
}
}
},
child: DeviceListItem(

View File

@ -1,50 +1,54 @@
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('/'),
return ListView(
padding: const EdgeInsets.fromLTRB(20, 16, 20, 24),
children: [
Text(
'Settings',
style: Theme.of(context).textTheme.headlineMedium?.copyWith(
fontWeight: FontWeight.w700,
),
),
),
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
},
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(
leading: Icon(Icons.brightness_6),
title: Text('Theme'),
subtitle: Text('Theme controls arrive in the next phase'),
),
Divider(height: 1),
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'),
),
],
),
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
},
),
],
),
),
],
);
}
}

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:shared_preferences/shared_preferences.dart';
@ -21,43 +23,61 @@ class SharedPrefNotifier<T> extends StateNotifier<T> {
// 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');
if (T == String) {
return prefs.getString(key) as T? ?? defaultValue;
}
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 {
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');
if (T == String) {
await prefs.setString(key, value as String);
state = value;
return;
}
state = value;
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,
);
});
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,
});
@override
_HorizontalScanningAnimationState createState() =>
State<HorizontalScanningAnimation> createState() =>
_HorizontalScanningAnimationState();
}
@ -117,8 +117,6 @@ class _HorizontalWavePainter extends CustomPainter {
double currentWidth =
width * easedProgress * 0.8; // Max 80% width expansion
double startX = (width / 2) - (currentWidth / 2);
double endX = (width / 2) + (currentWidth / 2);
// Calculate opacity based on progress (fade in and out)
double opacity;
if (waveProgress < 0.1) {
@ -130,8 +128,9 @@ class _HorizontalWavePainter extends CustomPainter {
}
opacity = max(0.0, opacity); // Clamp opacity
if (opacity <= 0.0 || currentWidth < 5)
continue; // Skip drawing if invisible or too small
if (opacity <= 0.0 || currentWidth < 5) {
continue;
}
// Create the wave path
final path = Path();

View File

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