From 74bb31ecc1be78964c27a766e612b2bf6a5f346a Mon Sep 17 00:00:00 2001 From: Pierre HUBERT Date: Mon, 7 Jul 2025 21:36:35 +0200 Subject: [PATCH] Load server configuration --- moneymgr_mobile/lib/providers/auth_state.dart | 22 +++--- .../lib/routes/profile/profile_screen.dart | 14 ++++ .../lib/services/api/api_client.dart | 23 +++--- .../lib/services/api/server_api.dart | 72 +++++++++++++++++++ .../lib/services/router/router.dart | 14 ++-- .../lib/services/storage/prefs.dart | 19 +++++ .../lib/widgets/full_screen_error.dart | 49 +++++++++++++ .../widgets/loaders/load_server_config.dart | 35 +++++++++ 8 files changed, 219 insertions(+), 29 deletions(-) create mode 100644 moneymgr_mobile/lib/routes/profile/profile_screen.dart create mode 100644 moneymgr_mobile/lib/services/api/server_api.dart create mode 100644 moneymgr_mobile/lib/widgets/full_screen_error.dart create mode 100644 moneymgr_mobile/lib/widgets/loaders/load_server_config.dart diff --git a/moneymgr_mobile/lib/providers/auth_state.dart b/moneymgr_mobile/lib/providers/auth_state.dart index 8927326..25aee1e 100644 --- a/moneymgr_mobile/lib/providers/auth_state.dart +++ b/moneymgr_mobile/lib/providers/auth_state.dart @@ -1,6 +1,7 @@ import 'package:moneymgr_mobile/services/api/api_client.dart'; import 'package:moneymgr_mobile/services/api/api_token.dart'; import 'package:moneymgr_mobile/services/api/auth_api.dart'; +import 'package:moneymgr_mobile/services/storage/prefs.dart'; import 'package:moneymgr_mobile/services/storage/secure_storage.dart'; import 'package:riverpod_annotation/riverpod_annotation.dart'; @@ -25,7 +26,10 @@ class CurrentAuthState extends _$CurrentAuthState { /// Will invalidate the state if success and throw an exception in case of failure Future setAuthToken(ApiToken token) async { // Attempt to use provided token - await ApiClient(token: token).authInfo(); + await ApiClient( + token: token, + prefs: await ref.watch(prefsProvider.future), + ).authInfo(); final secureStorage = ref.read(secureStorageProvider).requireValue; await secureStorage.setToken(token); @@ -37,19 +41,13 @@ class CurrentAuthState extends _$CurrentAuthState { /// 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'); + Future logout() async { + final secureStorage = ref.read(secureStorageProvider).requireValue; + await secureStorage.removeToken(); 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);*/ + // Invalidate the state so the auth state will be updated to authenticated. + .invalidateSelf(); } } diff --git a/moneymgr_mobile/lib/routes/profile/profile_screen.dart b/moneymgr_mobile/lib/routes/profile/profile_screen.dart new file mode 100644 index 0000000..94308da --- /dev/null +++ b/moneymgr_mobile/lib/routes/profile/profile_screen.dart @@ -0,0 +1,14 @@ +import 'package:flutter/material.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:moneymgr_mobile/services/storage/prefs.dart'; + +class ProfileScreen extends HookConsumerWidget { + const ProfileScreen({super.key}); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final data = ref.watch(prefsProvider); + if (data.value == null) return CircularProgressIndicator(); + return Text("dd\n\n\n${data.value!.serverConfig()}"); + } +} diff --git a/moneymgr_mobile/lib/services/api/api_client.dart b/moneymgr_mobile/lib/services/api/api_client.dart index 0362bb1..cd56928 100644 --- a/moneymgr_mobile/lib/services/api/api_client.dart +++ b/moneymgr_mobile/lib/services/api/api_client.dart @@ -2,8 +2,11 @@ import 'package:dart_jsonwebtoken/dart_jsonwebtoken.dart'; import 'package:dio/dio.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:moneymgr_mobile/services/api/api_token.dart'; +import 'package:moneymgr_mobile/services/storage/prefs.dart'; +import 'package:moneymgr_mobile/services/storage/secure_storage.dart'; import 'package:moneymgr_mobile/utils/string_utils.dart'; import 'package:riverpod_annotation/riverpod_annotation.dart'; +import 'package:shared_preferences/shared_preferences.dart'; part 'api_client.g.dart'; @@ -14,8 +17,9 @@ const apiTokenHeader = "X-Auth-Token"; class ApiClient { final ApiToken token; final Dio client; + final SharedPreferencesWithCache prefs; - ApiClient({required this.token}) + ApiClient({required this.token, required this.prefs}) : client = Dio(BaseOptions(baseUrl: token.apiUrl)); /// Get Dio instance @@ -54,19 +58,14 @@ class ApiClient { /// The API client is kept alive to follow dio's recommendation to use the same /// client instance for the entire app. @riverpod -ApiClient apiService(Ref ref) { - /*final token = ref.watch(currentAuthStateProvider); +ApiClient? apiService(Ref ref) { + final storage = ref.watch(secureStorageProvider); + final prefs = ref.watch(prefsProvider); - final ApiClient client; + final t = storage.value?.token(); + if (t == null || prefs.value == null) return null; - const mock = bool.fromEnvironment('MOCK_API', defaultValue: false); - client = switch (mock) { - true => - token != null ? MockedApiClient.withToken(token) : MockedApiClient(), - false => token != null ? ApiClient.withToken(token) : ApiClient(), - }; ref.keepAlive(); - return client;*/ - throw Exception("TODO"); // TODO + return ApiClient(token: t, prefs: prefs.value!); } diff --git a/moneymgr_mobile/lib/services/api/server_api.dart b/moneymgr_mobile/lib/services/api/server_api.dart new file mode 100644 index 0000000..4fcae9e --- /dev/null +++ b/moneymgr_mobile/lib/services/api/server_api.dart @@ -0,0 +1,72 @@ +// ignore_for_file: non_constant_identifier_names + +import 'package:freezed_annotation/freezed_annotation.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:logging/logging.dart'; +import 'package:moneymgr_mobile/services/storage/prefs.dart'; +import 'package:riverpod_annotation/riverpod_annotation.dart'; + +import 'api_client.dart'; + +part 'server_api.freezed.dart'; +part 'server_api.g.dart'; + +@freezed +abstract class ServerConstraint with _$ServerConstraint { + const factory ServerConstraint({required int min, required int max}) = + _ServerConstraint; + + factory ServerConstraint.fromJson(Map json) => + _$ServerConstraintFromJson(json); +} + +@freezed +abstract class ServerConstraints with _$ServerConstraints { + const factory ServerConstraints({required ServerConstraint inbox_entry_label}) = + _ServerConstraints; + + factory ServerConstraints.fromJson(Map json) => + _$ServerConstraintsFromJson(json); +} + +@freezed +abstract class ServerConfig with _$ServerConfig { + const factory ServerConfig({required ServerConstraints constraints}) = + _ServerConfig; + + factory ServerConfig.fromJson(Map json) => + _$ServerConfigFromJson(json); +} + +/// Auth API +extension ServerApi on ApiClient { + /// Get authentication information from server + Future serverConfig() async { + final response = await execute("/server/config", method: "GET"); + return ServerConfig.fromJson(response.data); + } + + +} + +/// Get authentication information from server, or retrieve cached information (if available, in +/// case of failure) +@riverpod +Future serverConfigOrCache(Ref ref) async { + final client = ref.watch(apiServiceProvider)!; + try { + final config = await client.serverConfig(); + client.prefs.setServerConfig(config); + return config; + } catch (e, s) { + Logger.root.warning("Failed to fetch server configuration! $e $s"); + } + + final cached = client.prefs.serverConfig(); + if (cached == null) { + throw Exception( + "Could not fetch server configuration, cached version is unavailable!", + ); + } + return cached; +} \ No newline at end of file diff --git a/moneymgr_mobile/lib/services/router/router.dart b/moneymgr_mobile/lib/services/router/router.dart index 150dde9..a0f3836 100644 --- a/moneymgr_mobile/lib/services/router/router.dart +++ b/moneymgr_mobile/lib/services/router/router.dart @@ -5,8 +5,10 @@ import 'package:moneymgr_mobile/providers/auth_state.dart'; import 'package:moneymgr_mobile/routes/login/login_screen.dart'; import 'package:moneymgr_mobile/routes/login/manual_auth_screen.dart'; import 'package:moneymgr_mobile/routes/login/qr_auth_screen.dart'; +import 'package:moneymgr_mobile/routes/profile/profile_screen.dart'; import 'package:moneymgr_mobile/routes/settings/settings_screen.dart'; import 'package:moneymgr_mobile/services/router/routes_list.dart'; +import 'package:moneymgr_mobile/widgets/loaders/load_server_config.dart'; import 'package:moneymgr_mobile/widgets/scaffold_with_navigation.dart'; import 'package:riverpod_annotation/riverpod_annotation.dart'; @@ -49,7 +51,7 @@ GoRouter router(Ref ref) { ), NavigationItem( path: profilePage, - body: (_) => const Text("Profile"), + body: (_) => ProfileScreen(), icon: Icons.person_outline, selectedIcon: Icons.person, label: 'Profile', @@ -79,10 +81,12 @@ GoRouter router(Ref ref) { GoRoute( path: item.path, pageBuilder: (context, _) => NoTransitionPage( - child: ScaffoldWithNavigation( - selectedIndex: index, - navigationItems: navigationItems, - child: item.body(context), + child: LoadServerConfig( + child: ScaffoldWithNavigation( + selectedIndex: index, + navigationItems: navigationItems, + child: item.body(context), + ), ), ), routes: item.routes, diff --git a/moneymgr_mobile/lib/services/storage/prefs.dart b/moneymgr_mobile/lib/services/storage/prefs.dart index c2ddff1..a9b4ae7 100644 --- a/moneymgr_mobile/lib/services/storage/prefs.dart +++ b/moneymgr_mobile/lib/services/storage/prefs.dart @@ -1,4 +1,7 @@ +import 'dart:convert'; + import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:moneymgr_mobile/services/api/server_api.dart'; import 'package:riverpod_annotation/riverpod_annotation.dart'; import 'package:shared_preferences/shared_preferences.dart'; @@ -9,3 +12,19 @@ Future prefs(Ref ref) => SharedPreferencesWithCache.create( cacheOptions: const SharedPreferencesWithCacheOptions(), ); + +extension MoneyMgrSharedPreferences on SharedPreferencesWithCache { + ServerConfig? serverConfig() { + final json = getString("serverConfig"); + if (json != null) return ServerConfig.fromJson(jsonDecode(json)); + return null; + } + + Future setServerConfig(ServerConfig config) async { + await setString("serverConfig", jsonEncode(config)); + } + + Future clearServerConfig() async { + await remove("serverConfig"); + } +} diff --git a/moneymgr_mobile/lib/widgets/full_screen_error.dart b/moneymgr_mobile/lib/widgets/full_screen_error.dart new file mode 100644 index 0000000..67ef75e --- /dev/null +++ b/moneymgr_mobile/lib/widgets/full_screen_error.dart @@ -0,0 +1,49 @@ +import 'package:flutter/material.dart'; + +class FullScreenError extends StatelessWidget { + final String message; + final String error; + final List? actions; + + const FullScreenError({ + super.key, + required this.message, + required this.error, + this.actions, + }); + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar(title: Text("Error")), + body: Padding( + padding: const EdgeInsets.all(8.0), + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Card( + elevation: 2, + color: Colors.red, + child: Padding( + padding: const EdgeInsets.all(8.0), + child: Column( + children: [ + Text( + message, + style: TextStyle(color: Colors.white, fontSize: 20), + ), + Text(error, style: TextStyle(color: Colors.white)), + ], + ), + ), + ), + Row( + mainAxisAlignment: MainAxisAlignment.center, + children: actions ?? [], + ), + ], + ), + ), + ); + } +} diff --git a/moneymgr_mobile/lib/widgets/loaders/load_server_config.dart b/moneymgr_mobile/lib/widgets/loaders/load_server_config.dart new file mode 100644 index 0000000..292a970 --- /dev/null +++ b/moneymgr_mobile/lib/widgets/loaders/load_server_config.dart @@ -0,0 +1,35 @@ +import 'package:flutter/material.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:moneymgr_mobile/providers/auth_state.dart'; +import 'package:moneymgr_mobile/services/api/server_api.dart'; +import 'package:moneymgr_mobile/widgets/full_screen_error.dart'; + +class LoadServerConfig extends HookConsumerWidget { + final Widget child; + + const LoadServerConfig({super.key, required this.child}); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final serverConfig = ref.watch(serverConfigOrCacheProvider); + + handleSignOut() { + ref.watch(currentAuthStateProvider.notifier).logout(); + } + + return switch (serverConfig) { + AsyncData() => child, + AsyncError(:final error) => FullScreenError( + message: "Failed to load server configuration!", + error: error.toString(), + actions: [ + MaterialButton( + onPressed: handleSignOut, + child: Text("Sign out".toUpperCase()), + ), + ], + ), + _ => const Scaffold(body: Center(child: CircularProgressIndicator())), + }; + } +}