Compare commits
2 Commits
ab8974c0a8
...
9b14a28d86
Author | SHA1 | Date | |
---|---|---|---|
9b14a28d86 | |||
29fec99b8f |
3
moneymgr_mobile/.gitignore
vendored
3
moneymgr_mobile/.gitignore
vendored
@ -45,4 +45,5 @@ app.*.map.json
|
||||
/android/app/release
|
||||
|
||||
|
||||
*.g.dart
|
||||
*.g.dart
|
||||
*.freezed.dart
|
@ -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);
|
||||
}
|
86
moneymgr_mobile/lib/routes/login/login_screen.dart
Normal file
86
moneymgr_mobile/lib/routes/login/login_screen.dart
Normal file
@ -0,0 +1,86 @@
|
||||
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/services/router/routes_list.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(settingsPage);
|
||||
|
||||
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('MoneyMgr'),
|
||||
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',
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
74
moneymgr_mobile/lib/routes/settings/settings_screen.dart
Normal file
74
moneymgr_mobile/lib/routes/settings/settings_screen.dart
Normal file
@ -0,0 +1,74 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:moneymgr_mobile/providers/settings.dart';
|
||||
import 'package:moneymgr_mobile/utils/extensions.dart';
|
||||
|
||||
class SettingsScreen extends ConsumerWidget {
|
||||
const SettingsScreen({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final themeMode = ref.watch(currentThemeModeProvider);
|
||||
|
||||
void onTapThemeMode() =>
|
||||
showDialog(context: context, builder: (_) => const _ThemeModeDialog());
|
||||
|
||||
void onTapLicenses() => context.showAppLicensePage();
|
||||
|
||||
return Scaffold(
|
||||
appBar: AppBar(title: const Text('Settings')),
|
||||
body: ListView(
|
||||
children: [
|
||||
ListTile(
|
||||
leading: const Icon(Icons.brightness_6),
|
||||
title: const Text('Theme mode'),
|
||||
trailing: Text(themeMode.label),
|
||||
onTap: onTapThemeMode,
|
||||
),
|
||||
const Divider(),
|
||||
ListTile(
|
||||
leading: const Icon(Icons.info_outline),
|
||||
title: const Text('Licenses'),
|
||||
onTap: onTapLicenses,
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Select theme dialog
|
||||
class _ThemeModeDialog extends ConsumerWidget {
|
||||
const _ThemeModeDialog();
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
void onTapOption(ThemeMode themeMode) {
|
||||
ref.read(currentThemeModeProvider.notifier).set(themeMode);
|
||||
Navigator.of(context).pop();
|
||||
}
|
||||
|
||||
return SimpleDialog(
|
||||
clipBehavior: Clip.antiAlias,
|
||||
children: [
|
||||
for (final themeMode in ThemeMode.values)
|
||||
_ThemeModeDialogOption(
|
||||
value: themeMode,
|
||||
onTap: () => onTapOption(themeMode),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _ThemeModeDialogOption extends StatelessWidget {
|
||||
const _ThemeModeDialogOption({required this.value, required this.onTap});
|
||||
|
||||
final ThemeMode value;
|
||||
final GestureTapCallback? onTap;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return ListTile(onTap: onTap, title: Text(value.label));
|
||||
}
|
||||
}
|
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: homePage, allowedPaths: [homePage]),
|
||||
unauthenticated(
|
||||
redirectPath: authPage,
|
||||
allowedPaths: [authPage, settingsPage],
|
||||
),
|
||||
authenticated(redirectPath: homePage, 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;
|
||||
}
|
108
moneymgr_mobile/lib/services/router/router.dart
Normal file
108
moneymgr_mobile/lib/services/router/router.dart
Normal file
@ -0,0 +1,108 @@
|
||||
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_screen.dart';
|
||||
import 'package:moneymgr_mobile/routes/settings/settings_screen.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: homePage, builder: (_, __) => const Scaffold()),
|
||||
GoRoute(path: authPage, builder: (_, __) => const LoginScreen()),
|
||||
GoRoute(
|
||||
path: settingsPage,
|
||||
builder: (_, __) => const SettingsScreen(),
|
||||
),
|
||||
|
||||
// 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 homePage = "/";
|
||||
|
||||
/// Authentication path
|
||||
const authPage = "/login";
|
||||
|
||||
/// Settings path
|
||||
const settingsPage = "/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);
|
||||
}
|
||||
}
|
39
moneymgr_mobile/lib/utils/extensions.dart
Normal file
39
moneymgr_mobile/lib/utils/extensions.dart
Normal file
@ -0,0 +1,39 @@
|
||||
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: 'MoneyMgr',
|
||||
applicationLegalese: '(c) Pierre HUBERT 2025 - ${DateTime.now().year}'
|
||||
);
|
||||
}
|
||||
|
||||
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 [],
|
||||
});
|
||||
}
|
@ -6,6 +6,10 @@
|
||||
|
||||
#include "generated_plugin_registrant.h"
|
||||
|
||||
#include <flutter_secure_storage_linux/flutter_secure_storage_linux_plugin.h>
|
||||
|
||||
void fl_register_plugins(FlPluginRegistry* registry) {
|
||||
g_autoptr(FlPluginRegistrar) flutter_secure_storage_linux_registrar =
|
||||
fl_plugin_registry_get_registrar_for_plugin(registry, "FlutterSecureStorageLinuxPlugin");
|
||||
flutter_secure_storage_linux_plugin_register_with_registrar(flutter_secure_storage_linux_registrar);
|
||||
}
|
||||
|
@ -3,6 +3,7 @@
|
||||
#
|
||||
|
||||
list(APPEND FLUTTER_PLUGIN_LIST
|
||||
flutter_secure_storage_linux
|
||||
)
|
||||
|
||||
list(APPEND FLUTTER_FFI_PLUGIN_LIST
|
||||
|
@ -5,8 +5,12 @@
|
||||
import FlutterMacOS
|
||||
import Foundation
|
||||
|
||||
import flutter_secure_storage_macos
|
||||
import path_provider_foundation
|
||||
import shared_preferences_foundation
|
||||
|
||||
func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) {
|
||||
FlutterSecureStoragePlugin.register(with: registry.registrar(forPlugin: "FlutterSecureStoragePlugin"))
|
||||
PathProviderPlugin.register(with: registry.registrar(forPlugin: "PathProviderPlugin"))
|
||||
SharedPreferencesPlugin.register(with: registry.registrar(forPlugin: "SharedPreferencesPlugin"))
|
||||
}
|
||||
|
@ -257,11 +257,35 @@ packages:
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.1.1"
|
||||
flextras:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
name: flextras
|
||||
sha256: e73b5c86dd9419569d2a48db470059b41b496012513e4e1bdc56ba2c661048d9
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.0.0"
|
||||
flutter:
|
||||
dependency: "direct main"
|
||||
description: flutter
|
||||
source: sdk
|
||||
version: "0.0.0"
|
||||
flutter_adaptive_scaffold:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: flutter_adaptive_scaffold
|
||||
sha256: "5eb1d1d174304a4e67c4bb402ed38cb4a5ebdac95ce54099e91460accb33d295"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "0.3.3+1"
|
||||
flutter_gutter:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
name: flutter_gutter
|
||||
sha256: "2aa99181796d6f7d2de66da962b71b0feb996ec69b7a1ad2ac1c2119f25b041b"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.2.0"
|
||||
flutter_hooks:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
@ -294,6 +318,54 @@ packages:
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.6.1"
|
||||
flutter_secure_storage:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
name: flutter_secure_storage
|
||||
sha256: "9cad52d75ebc511adfae3d447d5d13da15a55a92c9410e50f67335b6d21d16ea"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "9.2.4"
|
||||
flutter_secure_storage_linux:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: flutter_secure_storage_linux
|
||||
sha256: be76c1d24a97d0b98f8b54bce6b481a380a6590df992d0098f868ad54dc8f688
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.2.3"
|
||||
flutter_secure_storage_macos:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: flutter_secure_storage_macos
|
||||
sha256: "6c0a2795a2d1de26ae202a0d78527d163f4acbb11cde4c75c670f3a0fc064247"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "3.1.3"
|
||||
flutter_secure_storage_platform_interface:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: flutter_secure_storage_platform_interface
|
||||
sha256: cf91ad32ce5adef6fba4d736a542baca9daf3beac4db2d04be350b87f69ac4a8
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.1.2"
|
||||
flutter_secure_storage_web:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: flutter_secure_storage_web
|
||||
sha256: f4ebff989b4f07b2656fb16b47852c0aab9fed9b4ec1c70103368337bc1886a9
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.2.1"
|
||||
flutter_secure_storage_windows:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: flutter_secure_storage_windows
|
||||
sha256: b20b07cb5ed4ed74fc567b78a72936203f587eba460af1df11281c9326cd3709
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "3.1.2"
|
||||
flutter_test:
|
||||
dependency: "direct dev"
|
||||
description: flutter
|
||||
@ -304,8 +376,16 @@ packages:
|
||||
description: flutter
|
||||
source: sdk
|
||||
version: "0.0.0"
|
||||
freezed:
|
||||
dependency: "direct dev"
|
||||
description:
|
||||
name: freezed
|
||||
sha256: "6022db4c7bfa626841b2a10f34dd1e1b68e8f8f9650db6112dcdeeca45ca793c"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "3.0.6"
|
||||
freezed_annotation:
|
||||
dependency: transitive
|
||||
dependency: "direct main"
|
||||
description:
|
||||
name: freezed_annotation
|
||||
sha256: c87ff004c8aa6af2d531668b46a4ea379f7191dc6dfa066acd53d506da6e044b
|
||||
@ -404,18 +484,26 @@ packages:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: js
|
||||
sha256: "53385261521cc4a0c4658fd0ad07a7d14591cf8fc33abbceae306ddb974888dc"
|
||||
sha256: f2c445dce49627136094980615a031419f7f3eb393237e4ecd97ac15dea343f3
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "0.7.2"
|
||||
version: "0.6.7"
|
||||
json_annotation:
|
||||
dependency: transitive
|
||||
dependency: "direct main"
|
||||
description:
|
||||
name: json_annotation
|
||||
sha256: "1ce844379ca14835a50d2f019a3099f419082cfdd231cd86a142af94dd5c6bb1"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "4.9.0"
|
||||
json_serializable:
|
||||
dependency: "direct dev"
|
||||
description:
|
||||
name: json_serializable
|
||||
sha256: c50ef5fc083d5b5e12eef489503ba3bf5ccc899e487d691584699b4bdefeea8c
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "6.9.5"
|
||||
leak_tracker:
|
||||
dependency: transitive
|
||||
description:
|
||||
@ -504,6 +592,30 @@ packages:
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.9.1"
|
||||
path_provider:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: path_provider
|
||||
sha256: "50c5dd5b6e1aaf6fb3a78b33f6aa3afca52bf903a8a5298f53101fdaee55bbcd"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.1.5"
|
||||
path_provider_android:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: path_provider_android
|
||||
sha256: d0d310befe2c8ab9e7f393288ccbb11b60c019c6b5afc21973eeee4dda2b35e9
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.2.17"
|
||||
path_provider_foundation:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: path_provider_foundation
|
||||
sha256: "4843174df4d288f5e29185bd6e72a6fbdf5a4a4602717eed565497429f179942"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.4.1"
|
||||
path_provider_linux:
|
||||
dependency: transitive
|
||||
description:
|
||||
@ -701,6 +813,14 @@ packages:
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.0.0"
|
||||
source_helper:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: source_helper
|
||||
sha256: "86d247119aedce8e63f4751bd9626fc9613255935558447569ad42f9f5b48b3c"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.3.5"
|
||||
source_span:
|
||||
dependency: transitive
|
||||
description:
|
||||
@ -853,6 +973,14 @@ packages:
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "3.0.3"
|
||||
win32:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: win32
|
||||
sha256: "66814138c3562338d05613a6e368ed8cfb237ad6d64a9e9334be3f309acfca03"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "5.14.0"
|
||||
xdg_directories:
|
||||
dependency: transitive
|
||||
description:
|
||||
|
@ -38,6 +38,9 @@ dependencies:
|
||||
# Preferences management
|
||||
shared_preferences: ^2.5.3
|
||||
|
||||
# Credentials storage
|
||||
flutter_secure_storage: ^9.2.4
|
||||
|
||||
# Splash screen
|
||||
flutter_native_splash: ^2.4.6
|
||||
|
||||
@ -51,6 +54,15 @@ dependencies:
|
||||
# Router
|
||||
go_router: ^15.2.4
|
||||
|
||||
# Flutter extras widgets for columns and rows
|
||||
flextras: ^1.0.0
|
||||
flutter_gutter: ^2.2.0
|
||||
|
||||
# Help in models building
|
||||
freezed_annotation: ^3.0.0
|
||||
|
||||
json_annotation: ^4.9.0
|
||||
|
||||
dev_dependencies:
|
||||
flutter_test:
|
||||
sdk: flutter
|
||||
@ -68,6 +80,14 @@ dev_dependencies:
|
||||
# Riverpod code generation
|
||||
riverpod_generator: ^2.6.5
|
||||
|
||||
# Freezed code generation
|
||||
freezed: ^3.0.6
|
||||
|
||||
# JSON serialization
|
||||
json_serializable: ^6.9.5
|
||||
|
||||
|
||||
|
||||
# For information on the generic Dart part of this file, see the
|
||||
# following page: https://dart.dev/tools/pub/pubspec
|
||||
|
||||
|
@ -6,6 +6,9 @@
|
||||
|
||||
#include "generated_plugin_registrant.h"
|
||||
|
||||
#include <flutter_secure_storage_windows/flutter_secure_storage_windows_plugin.h>
|
||||
|
||||
void RegisterPlugins(flutter::PluginRegistry* registry) {
|
||||
FlutterSecureStorageWindowsPluginRegisterWithRegistrar(
|
||||
registry->GetRegistrarForPlugin("FlutterSecureStorageWindowsPlugin"));
|
||||
}
|
||||
|
@ -3,6 +3,7 @@
|
||||
#
|
||||
|
||||
list(APPEND FLUTTER_PLUGIN_LIST
|
||||
flutter_secure_storage_windows
|
||||
)
|
||||
|
||||
list(APPEND FLUTTER_FFI_PLUGIN_LIST
|
||||
|
Reference in New Issue
Block a user