feat(ui): add themed shell navigation
This commit is contained in:
@ -9,6 +9,12 @@
|
||||
# packages, and plugins designed to encourage good coding practices.
|
||||
include: package:flutter_lints/flutter.yaml
|
||||
|
||||
analyzer:
|
||||
errors:
|
||||
invalid_annotation_target: ignore
|
||||
plugins:
|
||||
- custom_lint
|
||||
|
||||
linter:
|
||||
# The lint rules applied to this project can be customized in the
|
||||
# section below to disable rules from the `package:flutter_lints/flutter.yaml`
|
||||
@ -23,12 +29,5 @@ linter:
|
||||
rules:
|
||||
# avoid_print: false # Uncomment to disable the `avoid_print` 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
|
||||
# https://dart.dev/guides/language/analysis-options
|
||||
|
||||
@ -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:abawo_bt_app/main.dart';
|
||||
import 'package:abawo_bt_app/src/rust/frb_generated.dart';
|
||||
import 'package:integration_test/integration_test.dart';
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
|
||||
void main() {
|
||||
IntegrationTestWidgetsFlutterBinding.ensureInitialized();
|
||||
setUpAll(() async => await RustLib.init());
|
||||
testWidgets('Can call rust function', (WidgetTester tester) async {
|
||||
await tester.pumpWidget(const MyApp());
|
||||
expect(find.textContaining('Result: `Hello, Tom!`'), findsOneWidget);
|
||||
|
||||
testWidgets('App launches to devices tab', (WidgetTester tester) async {
|
||||
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);
|
||||
});
|
||||
}
|
||||
|
||||
@ -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();
|
||||
|
||||
@ -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: [
|
||||
ShellRoute(
|
||||
builder: (context, state, child) => AppShell(
|
||||
currentLocation: state.uri.path,
|
||||
child: child,
|
||||
),
|
||||
routes: [
|
||||
GoRoute(
|
||||
path: '/',
|
||||
builder: (context, state) => const HomePage(),
|
||||
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) {
|
||||
|
||||
@ -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() {
|
||||
|
||||
@ -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,
|
||||
|
||||
67
lib/pages/devices_tab_page.dart
Normal file
67
lib/pages/devices_tab_page.dart
Normal 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(),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
@ -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,14 +197,19 @@ class _DevicesListState extends ConsumerState<DevicesList> {
|
||||
);
|
||||
}
|
||||
} catch (e) {
|
||||
if (!context.mounted) {
|
||||
return;
|
||||
}
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(content: Text('Error: ${e.toString()}')),
|
||||
);
|
||||
} finally {
|
||||
if (context.mounted) {
|
||||
setState(() {
|
||||
_connectingDeviceId = null;
|
||||
});
|
||||
}
|
||||
}
|
||||
},
|
||||
child: DeviceListItem(
|
||||
deviceName: device.deviceName,
|
||||
|
||||
@ -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('/'),
|
||||
),
|
||||
),
|
||||
body: ListView(
|
||||
return ListView(
|
||||
padding: const EdgeInsets.fromLTRB(20, 16, 20, 24),
|
||||
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
|
||||
},
|
||||
Text(
|
||||
'Settings',
|
||||
style: Theme.of(context).textTheme.headlineMedium?.copyWith(
|
||||
fontWeight: FontWeight.w700,
|
||||
),
|
||||
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(
|
||||
leading: const Icon(Icons.info),
|
||||
title: const Text('About'),
|
||||
subtitle: const Text('App information'),
|
||||
trailing: const Icon(Icons.chevron_right),
|
||||
onTap: () {
|
||||
// About screen functionality
|
||||
},
|
||||
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'),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
184
lib/theme/app_theme.dart
Normal file
184
lib/theme/app_theme.dart
Normal 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)),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@ -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:
|
||||
if (T == 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;
|
||||
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,
|
||||
);
|
||||
});
|
||||
|
||||
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);
|
||||
});
|
||||
|
||||
68
lib/widgets/app_shell.dart
Normal file
68
lib/widgets/app_shell.dart
Normal 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',
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@ -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();
|
||||
|
||||
@ -16,7 +16,7 @@ class ScanningWaveAnimation extends StatefulWidget {
|
||||
});
|
||||
|
||||
@override
|
||||
_ScanningWaveAnimationState createState() => _ScanningWaveAnimationState();
|
||||
State<ScanningWaveAnimation> createState() => _ScanningWaveAnimationState();
|
||||
}
|
||||
|
||||
class _ScanningWaveAnimationState extends State<ScanningWaveAnimation> {
|
||||
|
||||
Reference in New Issue
Block a user