feat(ui): add themed shell navigation
This commit is contained in:
@ -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
|
||||||
|
|||||||
@ -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);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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();
|
||||||
|
|||||||
@ -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: [
|
routes: [
|
||||||
GoRoute(
|
ShellRoute(
|
||||||
path: '/',
|
builder: (context, state, child) => AppShell(
|
||||||
builder: (context, state) => const HomePage(),
|
currentLocation: state.uri.path,
|
||||||
|
child: child,
|
||||||
|
),
|
||||||
routes: [
|
routes: [
|
||||||
GoRoute(
|
GoRoute(
|
||||||
path: 'settings',
|
path: '/devices',
|
||||||
builder: (context, state) => const SettingsPage(),
|
builder: (context, state) => const DevicesTabPage(),
|
||||||
),
|
),
|
||||||
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) {
|
||||||
|
|||||||
@ -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() {
|
||||||
|
|||||||
@ -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,
|
||||||
|
|||||||
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),
|
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,13 +197,18 @@ 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 {
|
||||||
setState(() {
|
if (context.mounted) {
|
||||||
_connectingDeviceId = null;
|
setState(() {
|
||||||
});
|
_connectingDeviceId = null;
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
child: DeviceListItem(
|
child: DeviceListItem(
|
||||||
|
|||||||
@ -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'),
|
children: [
|
||||||
leading: IconButton(
|
Text(
|
||||||
icon: const Icon(Icons.arrow_back),
|
'Settings',
|
||||||
onPressed: () => context.go('/'),
|
style: Theme.of(context).textTheme.headlineMedium?.copyWith(
|
||||||
|
fontWeight: FontWeight.w700,
|
||||||
|
),
|
||||||
),
|
),
|
||||||
),
|
const SizedBox(height: 6),
|
||||||
body: ListView(
|
Text(
|
||||||
children: [
|
'Theme, Bluetooth, and app details.',
|
||||||
ListTile(
|
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
|
||||||
leading: const Icon(Icons.brightness_6),
|
color: Theme.of(context)
|
||||||
title: const Text('Theme'),
|
.colorScheme
|
||||||
subtitle: const Text('Change app theme'),
|
.onSurface
|
||||||
trailing: const Icon(Icons.chevron_right),
|
.withValues(alpha: 0.68),
|
||||||
onTap: () {
|
),
|
||||||
// Theme settings functionality
|
),
|
||||||
},
|
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
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: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);
|
state = value;
|
||||||
break;
|
return;
|
||||||
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;
|
|
||||||
|
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);
|
||||||
|
});
|
||||||
|
|||||||
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,
|
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();
|
||||||
|
|||||||
@ -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> {
|
||||||
|
|||||||
Reference in New Issue
Block a user