Add base skeleton
This commit is contained in:
@ -3,7 +3,9 @@ import 'package:flutter_hooks/flutter_hooks.dart';
|
||||
import 'package:flutter_native_splash/flutter_native_splash.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:moneymgr_mobile/providers/settings.dart';
|
||||
import 'package:moneymgr_mobile/services/router/router.dart';
|
||||
import 'package:moneymgr_mobile/services/storage/prefs.dart';
|
||||
import 'package:moneymgr_mobile/services/storage/secure_storage.dart';
|
||||
import 'package:moneymgr_mobile/utils/provider_observer.dart';
|
||||
import 'package:moneymgr_mobile/utils/theme_utils.dart';
|
||||
|
||||
@ -16,10 +18,12 @@ Future<void> main() async {
|
||||
// app is inserted to the widget tree.
|
||||
FlutterNativeSplash.preserve(widgetsBinding: widgetsBinding);
|
||||
|
||||
runApp(ProviderScope(
|
||||
observers: [AppProviderObserver()],
|
||||
child: const MoneyMgrApp(),
|
||||
));
|
||||
runApp(
|
||||
ProviderScope(
|
||||
observers: [AppProviderObserver()],
|
||||
child: const MoneyMgrApp(),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
class MoneyMgrApp extends StatelessWidget {
|
||||
@ -27,21 +31,18 @@ class MoneyMgrApp extends StatelessWidget {
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return const _EagerInitialization(
|
||||
child: _MainApp(),
|
||||
);
|
||||
return const _EagerInitialization(child: _MainApp());
|
||||
}
|
||||
}
|
||||
|
||||
class _EagerInitialization extends ConsumerWidget {
|
||||
const _EagerInitialization({required this.child});
|
||||
|
||||
final Widget child;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final values = [
|
||||
ref.watch(prefsProvider),
|
||||
];
|
||||
final values = [ref.watch(prefsProvider), ref.watch(secureStorageProvider)];
|
||||
|
||||
if (values.every((value) => value.hasValue)) {
|
||||
return child;
|
||||
@ -58,7 +59,6 @@ class _MainApp extends StatefulHookConsumerWidget {
|
||||
ConsumerState<_MainApp> createState() => _MainAppState();
|
||||
}
|
||||
|
||||
|
||||
class _MainAppState extends ConsumerState<_MainApp> {
|
||||
@override
|
||||
void initState() {
|
||||
@ -71,15 +71,17 @@ class _MainAppState extends ConsumerState<_MainApp> {
|
||||
final router = ref.watch(routerProvider);
|
||||
final themeMode = ref.watch(currentThemeModeProvider);
|
||||
|
||||
final (lightTheme, darkTheme) = useMemoized(() => createDualThemeData(
|
||||
seedColor: Colors.blue,
|
||||
useMaterial3: true,
|
||||
transformer: (data) => data.copyWith(
|
||||
inputDecorationTheme: const InputDecorationTheme(
|
||||
border: OutlineInputBorder(),
|
||||
final (lightTheme, darkTheme) = useMemoized(
|
||||
() => createDualThemeData(
|
||||
seedColor: Colors.blue,
|
||||
useMaterial3: true,
|
||||
transformer: (data) => data.copyWith(
|
||||
inputDecorationTheme: const InputDecorationTheme(
|
||||
border: OutlineInputBorder(),
|
||||
),
|
||||
),
|
||||
),
|
||||
));
|
||||
);
|
||||
|
||||
return MaterialApp.router(
|
||||
title: 'MoneyMgr',
|
||||
@ -90,4 +92,4 @@ class _MainAppState extends ConsumerState<_MainApp> {
|
||||
debugShowCheckedModeBanner: false,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
16
moneymgr_mobile/lib/routes/login/login_model.dart
Normal file
16
moneymgr_mobile/lib/routes/login/login_model.dart
Normal file
@ -0,0 +1,16 @@
|
||||
import 'package:freezed_annotation/freezed_annotation.dart';
|
||||
|
||||
part 'login_model.freezed.dart';
|
||||
part 'login_model.g.dart';
|
||||
|
||||
@freezed
|
||||
abstract class LoginModel with _$LoginModel {
|
||||
const factory LoginModel({
|
||||
// TODO : replace
|
||||
required String username,
|
||||
required String password,
|
||||
}) = _LoginModel;
|
||||
|
||||
factory LoginModel.fromJson(Map<String, dynamic> json) =>
|
||||
_$LoginModelFromJson(json);
|
||||
}
|
85
moneymgr_mobile/lib/routes/login/login_screens.dart
Normal file
85
moneymgr_mobile/lib/routes/login/login_screens.dart
Normal file
@ -0,0 +1,85 @@
|
||||
import 'package:flextras/flextras.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_gutter/flutter_gutter.dart';
|
||||
import 'package:flutter_hooks/flutter_hooks.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:moneymgr_mobile/routes/login/login_model.dart';
|
||||
import 'package:moneymgr_mobile/widgets/app_button.dart';
|
||||
|
||||
import '../../../services/auth_state.dart';
|
||||
import '../../../utils/extensions.dart';
|
||||
|
||||
class LoginScreen extends HookConsumerWidget {
|
||||
const LoginScreen({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final isPasswordVisible = useState(false);
|
||||
|
||||
final usernameController = useTextEditingController();
|
||||
final passwordController = useTextEditingController();
|
||||
|
||||
void onSettingsPressed() => context.push('/settings');
|
||||
|
||||
Future<void> onLoginPressed() async {
|
||||
try {
|
||||
await ref.read(currentAuthStateProvider.notifier).login(LoginModel(
|
||||
username: usernameController.text,
|
||||
password: passwordController.text,
|
||||
));
|
||||
} on Exception catch (e) {
|
||||
if (!context.mounted) return;
|
||||
context.showTextSnackBar(e.toString());
|
||||
}
|
||||
}
|
||||
|
||||
return Scaffold(
|
||||
appBar: AppBar(
|
||||
title: const Text('Login'),
|
||||
actions: [
|
||||
IconButton(
|
||||
onPressed: onSettingsPressed,
|
||||
icon: const Icon(Icons.settings),
|
||||
),
|
||||
],
|
||||
),
|
||||
body: SeparatedColumn(
|
||||
padding: EdgeInsets.all(context.gutter),
|
||||
separatorBuilder: () => const Gutter(),
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
TextField(
|
||||
controller: usernameController,
|
||||
decoration: const InputDecoration(labelText: 'Username'),
|
||||
textInputAction: TextInputAction.next,
|
||||
),
|
||||
TextField(
|
||||
controller: passwordController,
|
||||
decoration: InputDecoration(
|
||||
labelText: 'Password',
|
||||
suffixIcon: IconButton(
|
||||
icon: Icon(
|
||||
isPasswordVisible.value
|
||||
? Icons.visibility_off
|
||||
: Icons.visibility,
|
||||
),
|
||||
onPressed: () =>
|
||||
isPasswordVisible.value = !isPasswordVisible.value,
|
||||
),
|
||||
),
|
||||
obscureText: !isPasswordVisible.value,
|
||||
keyboardType: TextInputType.visiblePassword,
|
||||
textInputAction: TextInputAction.done,
|
||||
),
|
||||
const SizedBox.shrink(),
|
||||
AppButton(
|
||||
onPressed: onLoginPressed,
|
||||
label: 'Login',
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
75
moneymgr_mobile/lib/services/auth_state.dart
Normal file
75
moneymgr_mobile/lib/services/auth_state.dart
Normal file
@ -0,0 +1,75 @@
|
||||
import 'package:moneymgr_mobile/routes/login/login_model.dart';
|
||||
import 'package:moneymgr_mobile/services/router/routes_list.dart';
|
||||
import 'package:moneymgr_mobile/services/storage/secure_storage.dart';
|
||||
import 'package:riverpod_annotation/riverpod_annotation.dart';
|
||||
|
||||
part 'auth_state.g.dart';
|
||||
|
||||
/// The current authentication state of the app.
|
||||
///
|
||||
/// This notifier is responsible for saving/removing the token and profile info
|
||||
/// to the storage through the [login] and [logout] methods.
|
||||
@riverpod
|
||||
class CurrentAuthState extends _$CurrentAuthState {
|
||||
@override
|
||||
AuthState build() {
|
||||
final secureStorage = ref.watch(secureStorageProvider).requireValue;
|
||||
final token = secureStorage.get('token');
|
||||
return token != null ? AuthState.authenticated : AuthState.unauthenticated;
|
||||
}
|
||||
|
||||
/// Attempts to log in with [data] and saves the token and profile info to storage.
|
||||
/// Will invalidate the state if success.
|
||||
Future<void> login(LoginModel data) async {
|
||||
// TODO : perform login
|
||||
/*final secureStorage = ref.read(secureStorageProvider).requireValue;
|
||||
final token = await ref.read(apiServiceProvider).login(data);
|
||||
|
||||
// Save the new [token] and [profile] to secure storage.
|
||||
secureStorage.set('token', token);
|
||||
|
||||
ref
|
||||
// Invalidate the state so the auth state will be updated to authenticated.
|
||||
..invalidateSelf()
|
||||
// Invalidate the token provider so the API service will use the new token.
|
||||
..invalidate(tokenProvider);*/
|
||||
}
|
||||
|
||||
/// Logs out, deletes the saved token and profile info from storage, and invalidates
|
||||
/// the state.
|
||||
void logout() {
|
||||
// TODO : implement logic
|
||||
/*final secureStorage = ref.read(secureStorageProvider).requireValue;
|
||||
|
||||
// Delete the current [token] and [profile] from secure storage.
|
||||
secureStorage.remove('token');
|
||||
|
||||
ref
|
||||
// Invalidate the state so the auth state will be updated to unauthenticated.
|
||||
..invalidateSelf()
|
||||
// Invalidate the token provider so the API service will no longer use the
|
||||
// previous token.
|
||||
..invalidate(tokenProvider);*/
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/// The possible authentication states of the app.
|
||||
enum AuthState {
|
||||
unknown(redirectPath: homePath, allowedPaths: [homePath]),
|
||||
unauthenticated(
|
||||
redirectPath: authPath,
|
||||
allowedPaths: [authPath, settingsPath],
|
||||
),
|
||||
authenticated(redirectPath: homePath, allowedPaths: null);
|
||||
|
||||
const AuthState({required this.redirectPath, required this.allowedPaths});
|
||||
|
||||
/// The target path to redirect when the current route is not allowed in this
|
||||
/// auth state.
|
||||
final String redirectPath;
|
||||
|
||||
/// List of paths allowed when the app is in this auth state. May be set to null if there is no
|
||||
/// restriction applicable
|
||||
final List<String>? allowedPaths;
|
||||
}
|
107
moneymgr_mobile/lib/services/router/router.dart
Normal file
107
moneymgr_mobile/lib/services/router/router.dart
Normal file
@ -0,0 +1,107 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:moneymgr_mobile/routes/login/login_screens.dart';
|
||||
import 'package:moneymgr_mobile/services/auth_state.dart';
|
||||
import 'package:moneymgr_mobile/services/router/routes_list.dart';
|
||||
import 'package:moneymgr_mobile/widgets/scaffold_with_navigation.dart';
|
||||
import 'package:riverpod_annotation/riverpod_annotation.dart';
|
||||
|
||||
|
||||
part 'router.g.dart';
|
||||
|
||||
/// The router config for the app.
|
||||
@riverpod
|
||||
GoRouter router(Ref ref) {
|
||||
// Local notifier for the current auth state. The purpose of this notifier is
|
||||
// to provide a [Listenable] to the [GoRouter] exposed by this provider.
|
||||
final authStateNotifier = ValueNotifier(AuthState.unknown);
|
||||
ref
|
||||
..onDispose(authStateNotifier.dispose)
|
||||
..listen(currentAuthStateProvider, (_, value) {
|
||||
authStateNotifier.value = value;
|
||||
});
|
||||
|
||||
// This is the only place you need to define your navigation items. The items
|
||||
// will be propagated automatically to the router and the navigation bar/rail
|
||||
// of the scaffold.
|
||||
//
|
||||
// To configure the authentication state needed to access a particular item,
|
||||
// see [AuthState] enum.
|
||||
final navigationItems = [
|
||||
NavigationItem(
|
||||
path: '/products',
|
||||
body: (_) => const Text("product screen"),
|
||||
icon: Icons.widgets_outlined,
|
||||
selectedIcon: Icons.widgets,
|
||||
label: 'Products',
|
||||
routes: [
|
||||
GoRoute(
|
||||
path: ':id',
|
||||
builder: (_, state) {
|
||||
final id = int.parse(state.pathParameters['id']!);
|
||||
return Text("product screen $id");
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
NavigationItem(
|
||||
path: '/profile',
|
||||
body: (_) => const Text("Profile"),
|
||||
icon: Icons.person_outline,
|
||||
selectedIcon: Icons.person,
|
||||
label: 'Profile',
|
||||
),
|
||||
];
|
||||
|
||||
final router = GoRouter(
|
||||
debugLogDiagnostics: true,
|
||||
initialLocation: navigationItems.first.path,
|
||||
routes: [
|
||||
GoRoute(path: homePath, builder: (_, __) => const Scaffold()),
|
||||
GoRoute(path: authPath, builder: (_, __) => const LoginScreen()),
|
||||
GoRoute(
|
||||
path: settingsPath,
|
||||
builder: (_, __) => const Text("settings screen"),
|
||||
),
|
||||
|
||||
// Configuration for the bottom navigation bar routes. The routes themselves
|
||||
// should be defined in [navigationItems]. Modification to this [ShellRoute]
|
||||
// config is rarely needed.
|
||||
ShellRoute(
|
||||
builder: (_, __, child) => child,
|
||||
routes: [
|
||||
for (final (index, item) in navigationItems.indexed)
|
||||
GoRoute(
|
||||
path: item.path,
|
||||
pageBuilder: (context, _) => NoTransitionPage(
|
||||
child: ScaffoldWithNavigation(
|
||||
selectedIndex: index,
|
||||
navigationItems: navigationItems,
|
||||
child: item.body(context),
|
||||
),
|
||||
),
|
||||
routes: item.routes,
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
refreshListenable: authStateNotifier,
|
||||
redirect: (_, state) {
|
||||
// Get the current auth state.
|
||||
final authState = ref.read(currentAuthStateProvider);
|
||||
|
||||
// Check if the current path is allowed for the current auth state. If not,
|
||||
// redirect to the redirect target of the current auth state.
|
||||
if (authState.allowedPaths?.contains(state.fullPath) == false) {
|
||||
return authState.redirectPath;
|
||||
}
|
||||
|
||||
// If the current path is allowed for the current auth state, don't redirect.
|
||||
return null;
|
||||
},
|
||||
);
|
||||
ref.onDispose(router.dispose);
|
||||
|
||||
return router;
|
||||
}
|
7
moneymgr_mobile/lib/services/router/routes_list.dart
Normal file
7
moneymgr_mobile/lib/services/router/routes_list.dart
Normal file
@ -0,0 +1,7 @@
|
||||
const homePath = "/";
|
||||
|
||||
/// Authentication path
|
||||
const authPath = "/login";
|
||||
|
||||
/// Settings path
|
||||
const settingsPath = "/settings";
|
42
moneymgr_mobile/lib/services/storage/secure_storage.dart
Normal file
42
moneymgr_mobile/lib/services/storage/secure_storage.dart
Normal file
@ -0,0 +1,42 @@
|
||||
import 'package:flutter_secure_storage/flutter_secure_storage.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:riverpod_annotation/riverpod_annotation.dart';
|
||||
|
||||
part 'secure_storage.g.dart';
|
||||
|
||||
@riverpod
|
||||
Future<SecureStorage> secureStorage(Ref ref) =>
|
||||
SecureStorage.getInstance(keys: {'token'});
|
||||
|
||||
class SecureStorage {
|
||||
SecureStorage._(this._flutterSecureStorage, this._cache);
|
||||
|
||||
late final FlutterSecureStorage _flutterSecureStorage;
|
||||
|
||||
late final Map<String, String> _cache;
|
||||
|
||||
static Future<SecureStorage> getInstance({required Set<String> keys}) async {
|
||||
const flutterSecureStorage = FlutterSecureStorage();
|
||||
final cache = <String, String>{};
|
||||
await keys
|
||||
.map((key) => flutterSecureStorage.read(key: key).then((value) {
|
||||
if (value != null) {
|
||||
cache[key] = value;
|
||||
}
|
||||
}))
|
||||
.wait;
|
||||
return SecureStorage._(flutterSecureStorage, cache);
|
||||
}
|
||||
|
||||
String? get(String key) => _cache[key];
|
||||
|
||||
Future<void> set(String key, String value) {
|
||||
_cache[key] = value;
|
||||
return _flutterSecureStorage.write(key: key, value: value);
|
||||
}
|
||||
|
||||
Future<void> remove(String key) {
|
||||
_cache.remove(key);
|
||||
return _flutterSecureStorage.delete(key: key);
|
||||
}
|
||||
}
|
38
moneymgr_mobile/lib/utils/extensions.dart
Normal file
38
moneymgr_mobile/lib/utils/extensions.dart
Normal file
@ -0,0 +1,38 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
extension BuildContextX on BuildContext {
|
||||
/// A convenient way to access [ThemeData.colorScheme] of the current context.
|
||||
///
|
||||
/// This also prevents confusion with a bunch of other properties of [ThemeData]
|
||||
/// that are less commonly used.
|
||||
ColorScheme get colorScheme => Theme.of(this).colorScheme;
|
||||
|
||||
/// A convenient way to access [ThemeData.textTheme] of the current context.
|
||||
///
|
||||
/// This also prevents confusion with a bunch of other properties of [ThemeData]
|
||||
/// that are less commonly used.
|
||||
TextTheme get textTheme => Theme.of(this).textTheme;
|
||||
|
||||
/// Shows a floating snack bar with text as its content.
|
||||
ScaffoldFeatureController<SnackBar, SnackBarClosedReason> showTextSnackBar(
|
||||
String text,
|
||||
) =>
|
||||
ScaffoldMessenger.of(this).showSnackBar(SnackBar(
|
||||
behavior: SnackBarBehavior.floating,
|
||||
content: Text(text),
|
||||
));
|
||||
|
||||
void showAppLicensePage() => showLicensePage(
|
||||
context: this,
|
||||
useRootNavigator: true,
|
||||
applicationName: 'DummyMart',
|
||||
);
|
||||
}
|
||||
|
||||
extension ThemeModeX on ThemeMode {
|
||||
String get label => switch (this) {
|
||||
ThemeMode.system => 'System',
|
||||
ThemeMode.light => 'Light',
|
||||
ThemeMode.dark => 'Dark',
|
||||
};
|
||||
}
|
19
moneymgr_mobile/lib/utils/hooks.dart
Normal file
19
moneymgr_mobile/lib/utils/hooks.dart
Normal file
@ -0,0 +1,19 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_hooks/flutter_hooks.dart';
|
||||
|
||||
typedef AsyncTask = ({
|
||||
ValueNotifier<Future<void>?> pending,
|
||||
AsyncSnapshot<void> snapshot,
|
||||
bool hasError,
|
||||
});
|
||||
|
||||
/// Creates a hook that provides a [snapshot] of the current asynchronous task passed
|
||||
/// to [pending] and a [hasError] value.
|
||||
AsyncTask useAsyncTask() {
|
||||
final pending = useState<Future<void>?>(null);
|
||||
final snapshot = useFuture(pending.value);
|
||||
final hasError =
|
||||
snapshot.hasError && snapshot.connectionState != ConnectionState.waiting;
|
||||
|
||||
return (pending: pending, snapshot: snapshot, hasError: hasError);
|
||||
}
|
45
moneymgr_mobile/lib/widgets/app_button.dart
Normal file
45
moneymgr_mobile/lib/widgets/app_button.dart
Normal file
@ -0,0 +1,45 @@
|
||||
import 'package:flextras/flextras.dart';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_gutter/flutter_gutter.dart';
|
||||
import 'package:flutter_hooks/flutter_hooks.dart';
|
||||
import 'package:moneymgr_mobile/utils/extensions.dart';
|
||||
import 'package:moneymgr_mobile/utils/hooks.dart';
|
||||
|
||||
|
||||
/// A button that shows a circular progress indicator when the [onPressed] callback
|
||||
/// is pending.
|
||||
class AppButton extends HookWidget {
|
||||
const AppButton({
|
||||
super.key,
|
||||
required this.onPressed,
|
||||
required this.label,
|
||||
});
|
||||
|
||||
final AsyncCallback? onPressed;
|
||||
final String label;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final (:pending, :snapshot, hasError: _) = useAsyncTask();
|
||||
|
||||
return FilledButton(
|
||||
onPressed: onPressed == null ? null : () => pending.value = onPressed!(),
|
||||
child: SeparatedRow(
|
||||
separatorBuilder: () => const GutterSmall(),
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
if (snapshot.connectionState == ConnectionState.waiting)
|
||||
SizedBox.square(
|
||||
dimension: 12,
|
||||
child: CircularProgressIndicator(
|
||||
strokeWidth: 2,
|
||||
color: context.colorScheme.onPrimary,
|
||||
),
|
||||
),
|
||||
Text(label),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
100
moneymgr_mobile/lib/widgets/scaffold_with_navigation.dart
Normal file
100
moneymgr_mobile/lib/widgets/scaffold_with_navigation.dart
Normal file
@ -0,0 +1,100 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
|
||||
/// A scaffold that shows navigation bar/rail when the current path is a navigation
|
||||
/// item.
|
||||
///
|
||||
/// When in a navigation item, a [NavigationBar] will be shown if the width of the
|
||||
/// screen is less than 600dp. Otherwise, a [NavigationRail] will be shown.
|
||||
class ScaffoldWithNavigation extends StatelessWidget {
|
||||
const ScaffoldWithNavigation({
|
||||
super.key,
|
||||
required this.child,
|
||||
required this.selectedIndex,
|
||||
required this.navigationItems,
|
||||
});
|
||||
|
||||
final Widget child;
|
||||
final int selectedIndex;
|
||||
final List<NavigationItem> navigationItems;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
void onDestinationSelected(int index) =>
|
||||
context.go(navigationItems[index].path);
|
||||
|
||||
// Use navigation rail instead of navigation bar when the screen width is
|
||||
// larger than 600dp.
|
||||
if (MediaQuery.sizeOf(context).width > 600) {
|
||||
return Scaffold(
|
||||
body: Row(
|
||||
children: [
|
||||
NavigationRail(
|
||||
selectedIndex: selectedIndex,
|
||||
onDestinationSelected: onDestinationSelected,
|
||||
destinations: [
|
||||
for (final item in navigationItems)
|
||||
NavigationRailDestination(
|
||||
icon: Icon(item.icon),
|
||||
selectedIcon: item.selectedIcon != null
|
||||
? Icon(item.selectedIcon)
|
||||
: null,
|
||||
label: Text(item.label),
|
||||
)
|
||||
],
|
||||
extended: true,
|
||||
),
|
||||
Expanded(child: child),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
return Scaffold(
|
||||
body: child,
|
||||
bottomNavigationBar: NavigationBar(
|
||||
selectedIndex: selectedIndex,
|
||||
onDestinationSelected: onDestinationSelected,
|
||||
destinations: [
|
||||
for (final item in navigationItems)
|
||||
NavigationDestination(
|
||||
icon: Icon(item.icon),
|
||||
selectedIcon:
|
||||
item.selectedIcon != null ? Icon(item.selectedIcon) : null,
|
||||
label: item.label,
|
||||
)
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// An item that represents a navigation destination in a navigation bar/rail.
|
||||
class NavigationItem {
|
||||
/// Path in the router.
|
||||
final String path;
|
||||
|
||||
/// Widget to show when navigating to this [path].
|
||||
final WidgetBuilder body;
|
||||
|
||||
/// Icon in the navigation bar.
|
||||
final IconData icon;
|
||||
|
||||
/// Icon in the navigation bar when selected.
|
||||
final IconData? selectedIcon;
|
||||
|
||||
/// Label in the navigation bar.
|
||||
final String label;
|
||||
|
||||
/// The subroutes of the route from this [path].
|
||||
final List<RouteBase> routes;
|
||||
|
||||
NavigationItem({
|
||||
required this.path,
|
||||
required this.body,
|
||||
required this.icon,
|
||||
this.selectedIcon,
|
||||
required this.label,
|
||||
this.routes = const [],
|
||||
});
|
||||
}
|
Reference in New Issue
Block a user