From 1334f546ee30b2a0c73168fe582f38844ea17f17 Mon Sep 17 00:00:00 2001 From: Anton Stubenbord Date: Fri, 14 Apr 2023 01:34:34 +0200 Subject: [PATCH] feat: Split settings, encrypted user credentials, preparations for multi user support --- .../custpm_adapters/theme_mode_adapter.dart | 47 +++++++ lib/core/config/hive/hive_config.dart | 39 ++++++ .../hydrated_storage_extension.dart | 13 -- lib/features/app_drawer/view/app_drawer.dart | 5 +- .../confirm_bulk_modify_label_dialog.dart | 3 + .../confirm_bulk_modify_tags_dialog.dart | 3 + .../widgets/document_download_button.dart | 6 +- .../widgets/items/document_list_item.dart | 9 +- .../view/widget/verify_identity_page.dart | 75 ++++++----- .../login/cubit/authentication_cubit.dart | 118 ++++++++++++------ .../login/cubit/authentication_state.dart | 6 +- .../model/authentication_information.dart | 40 +++--- .../login/model/client_certificate.dart | 7 ++ .../settings/global_app_settings.dart | 36 ++++++ .../settings/model/color_scheme_option.dart | 8 ++ lib/features/settings/user_app_settings.dart | 16 +++ .../view/dialogs/account_settings_dialog.dart | 3 +- lib/features/settings/view/settings_page.dart | 7 +- .../biometric_authentication_setting.dart | 28 +++-- .../widgets/color_scheme_option_setting.dart | 18 +-- .../view/widgets/global_settings_builder.dart | 25 ++++ .../widgets/language_selection_setting.dart | 13 +- .../view/widgets/theme_mode_setting.dart | 11 +- .../view/widgets/user_settings_builder.dart | 37 ++++++ lib/main.dart | 53 ++++++-- pubspec.lock | 64 ++++++++++ pubspec.yaml | 3 + 27 files changed, 520 insertions(+), 173 deletions(-) create mode 100644 lib/core/config/hive/custpm_adapters/theme_mode_adapter.dart create mode 100644 lib/core/config/hive/hive_config.dart delete mode 100644 lib/extensions/hydrated_storage_extension.dart create mode 100644 lib/features/settings/global_app_settings.dart create mode 100644 lib/features/settings/user_app_settings.dart create mode 100644 lib/features/settings/view/widgets/global_settings_builder.dart create mode 100644 lib/features/settings/view/widgets/user_settings_builder.dart diff --git a/lib/core/config/hive/custpm_adapters/theme_mode_adapter.dart b/lib/core/config/hive/custpm_adapters/theme_mode_adapter.dart new file mode 100644 index 0000000..18d50f2 --- /dev/null +++ b/lib/core/config/hive/custpm_adapters/theme_mode_adapter.dart @@ -0,0 +1,47 @@ +import 'package:flutter/material.dart'; +import 'package:hive_flutter/adapters.dart'; +import 'package:paperless_mobile/core/config/hive/hive_config.dart'; + +class ThemeModeAdapter extends TypeAdapter { + @override + final int typeId = HiveTypeIds.themeMode; + + @override + ThemeMode read(BinaryReader reader) { + switch (reader.readByte()) { + case 0: + return ThemeMode.system; + case 1: + return ThemeMode.dark; + case 2: + return ThemeMode.light; + default: + return ThemeMode.system; + } + } + + @override + void write(BinaryWriter writer, ThemeMode obj) { + switch (obj) { + case ThemeMode.system: + writer.writeByte(0); + break; + case ThemeMode.light: + writer.writeByte(1); + break; + case ThemeMode.dark: + writer.writeByte(2); + break; + } + } + + @override + int get hashCode => typeId.hashCode; + + @override + bool operator ==(Object other) => + identical(this, other) || + other is ThemeModeAdapter && + runtimeType == other.runtimeType && + typeId == other.typeId; +} diff --git a/lib/core/config/hive/hive_config.dart b/lib/core/config/hive/hive_config.dart new file mode 100644 index 0000000..7802e42 --- /dev/null +++ b/lib/core/config/hive/hive_config.dart @@ -0,0 +1,39 @@ +import 'package:hive_flutter/adapters.dart'; +import 'package:paperless_mobile/core/config/hive/custpm_adapters/theme_mode_adapter.dart'; +import 'package:paperless_mobile/features/login/model/authentication_information.dart'; +import 'package:paperless_mobile/features/login/model/client_certificate.dart'; +import 'package:paperless_mobile/features/settings/global_app_settings.dart'; +import 'package:paperless_mobile/features/settings/model/color_scheme_option.dart'; +import 'package:paperless_mobile/features/settings/user_app_settings.dart'; + +class HiveBoxes { + HiveBoxes._(); + static const globalSettings = 'globalSettings'; + static const userSettings = 'userSettings'; + static const authentication = 'authentication'; + static const vault = 'vault'; +} + +class HiveTypeIds { + HiveTypeIds._(); + static const globalSettings = 0; + static const userSettings = 1; + static const themeMode = 2; + static const colorSchemeOption = 3; + static const authentication = 4; + static const clientCertificate = 5; +} + +class HiveBoxSingleValueKey { + HiveBoxSingleValueKey._(); + static const value = 'value'; +} + +void registerHiveAdapters() { + Hive.registerAdapter(ColorSchemeOptionAdapter()); + Hive.registerAdapter(ThemeModeAdapter()); + Hive.registerAdapter(GlobalAppSettingsAdapter()); + Hive.registerAdapter(UserAppSettingsAdapter()); + Hive.registerAdapter(AuthenticationInformationAdapter()); + Hive.registerAdapter(ClientCertificateAdapter()); +} diff --git a/lib/extensions/hydrated_storage_extension.dart b/lib/extensions/hydrated_storage_extension.dart deleted file mode 100644 index f462ed6..0000000 --- a/lib/extensions/hydrated_storage_extension.dart +++ /dev/null @@ -1,13 +0,0 @@ -import 'package:hydrated_bloc/hydrated_bloc.dart'; -import 'package:paperless_mobile/features/login/cubit/authentication_cubit.dart'; -import 'package:paperless_mobile/features/settings/cubit/application_settings_cubit.dart'; - -extension AddressableHydratedStorage on Storage { - ApplicationSettingsState get settings { - return ApplicationSettingsState.fromJson(read('ApplicationSettingsCubit')); - } - - AuthenticationState get authentication { - return AuthenticationState.fromJson(read('AuthenticationCubit')); - } -} diff --git a/lib/features/app_drawer/view/app_drawer.dart b/lib/features/app_drawer/view/app_drawer.dart index 7dd6b12..2399d19 100644 --- a/lib/features/app_drawer/view/app_drawer.dart +++ b/lib/features/app_drawer/view/app_drawer.dart @@ -69,10 +69,7 @@ class AppDrawer extends StatelessWidget { ), onTap: () => Navigator.of(context).push( MaterialPageRoute( - builder: (context) => BlocProvider.value( - value: context.read(), - child: const SettingsPage(), - ), + builder: (context) => const SettingsPage(), ), ), ), diff --git a/lib/features/document_bulk_action/view/widgets/confirm_bulk_modify_label_dialog.dart b/lib/features/document_bulk_action/view/widgets/confirm_bulk_modify_label_dialog.dart index 98fbb4d..78cec08 100644 --- a/lib/features/document_bulk_action/view/widgets/confirm_bulk_modify_label_dialog.dart +++ b/lib/features/document_bulk_action/view/widgets/confirm_bulk_modify_label_dialog.dart @@ -12,10 +12,13 @@ class ConfirmBulkModifyLabelDialog extends StatelessWidget { @override Widget build(BuildContext context) { + final theme = Theme.of(context); return AlertDialog( title: Text(S.of(context)!.confirmAction), content: RichText( text: TextSpan( + style: theme.textTheme.bodyMedium + ?.copyWith(color: theme.colorScheme.onSurfaceVariant), text: content, children: [ const TextSpan(text: "\n\n"), diff --git a/lib/features/document_bulk_action/view/widgets/confirm_bulk_modify_tags_dialog.dart b/lib/features/document_bulk_action/view/widgets/confirm_bulk_modify_tags_dialog.dart index 5efa496..9353799 100644 --- a/lib/features/document_bulk_action/view/widgets/confirm_bulk_modify_tags_dialog.dart +++ b/lib/features/document_bulk_action/view/widgets/confirm_bulk_modify_tags_dialog.dart @@ -18,10 +18,13 @@ class ConfirmBulkModifyTagsDialog extends StatelessWidget { @override Widget build(BuildContext context) { + final theme = Theme.of(context); return AlertDialog( title: Text(S.of(context)!.confirmAction), content: RichText( text: TextSpan( + style: theme.textTheme.bodyMedium + ?.copyWith(color: theme.colorScheme.onSurfaceVariant), text: _buildText(context), children: [ const TextSpan(text: "\n\n"), diff --git a/lib/features/document_details/view/widgets/document_download_button.dart b/lib/features/document_details/view/widgets/document_download_button.dart index 63d98ae..c4d31c8 100644 --- a/lib/features/document_details/view/widgets/document_download_button.dart +++ b/lib/features/document_details/view/widgets/document_download_button.dart @@ -5,6 +5,7 @@ import 'package:paperless_api/paperless_api.dart'; import 'package:paperless_mobile/extensions/flutter_extensions.dart'; import 'package:paperless_mobile/features/document_details/cubit/document_details_cubit.dart'; import 'package:paperless_mobile/features/document_details/view/dialogs/select_file_type_dialog.dart'; +import 'package:paperless_mobile/features/settings/global_app_settings.dart'; import 'package:paperless_mobile/features/settings/cubit/application_settings_cubit.dart'; import 'package:paperless_mobile/generated/l10n/app_localizations.dart'; @@ -69,10 +70,7 @@ class _DocumentDownloadButtonState extends State { setState(() => _isDownloadPending = true); await context.read().downloadDocument( downloadOriginal: downloadOriginal, - locale: context - .read() - .state - .preferredLocaleSubtag, + locale: context.read().preferredLocaleSubtag, ); // showSnackBar(context, S.of(context)!.documentSuccessfullyDownloaded); } on PaperlessServerException catch (error, stackTrace) { diff --git a/lib/features/documents/view/widgets/items/document_list_item.dart b/lib/features/documents/view/widgets/items/document_list_item.dart index cb95c1b..bf16352 100644 --- a/lib/features/documents/view/widgets/items/document_list_item.dart +++ b/lib/features/documents/view/widgets/items/document_list_item.dart @@ -1,17 +1,14 @@ import 'package:flutter/material.dart'; -import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:intl/intl.dart'; -import 'package:paperless_api/paperless_api.dart'; import 'package:paperless_mobile/features/documents/view/widgets/document_preview.dart'; import 'package:paperless_mobile/features/documents/view/widgets/items/document_item.dart'; -import 'package:paperless_mobile/features/labels/cubit/label_cubit.dart'; import 'package:paperless_mobile/features/labels/correspondent/view/widgets/correspondent_widget.dart'; import 'package:paperless_mobile/features/labels/tags/view/widgets/tags_widget.dart'; class DocumentListItem extends DocumentItem { static const _a4AspectRatio = 1 / 1.4142; - DocumentListItem({ + const DocumentListItem({ super.key, required super.document, required super.isSelected, @@ -28,9 +25,7 @@ class DocumentListItem extends DocumentItem { required super.correspondents, required super.documentTypes, required super.storagePaths, - }) { - print(tags.keys.join(", ")); - } + }); @override Widget build(BuildContext context) { diff --git a/lib/features/home/view/widget/verify_identity_page.dart b/lib/features/home/view/widget/verify_identity_page.dart index ea63ba1..1a4035a 100644 --- a/lib/features/home/view/widget/verify_identity_page.dart +++ b/lib/features/home/view/widget/verify_identity_page.dart @@ -5,7 +5,10 @@ import 'package:paperless_mobile/core/repository/label_repository.dart'; import 'package:paperless_mobile/core/repository/saved_view_repository.dart'; import 'package:paperless_mobile/extensions/flutter_extensions.dart'; import 'package:paperless_mobile/features/login/cubit/authentication_cubit.dart'; +import 'package:paperless_mobile/features/settings/global_app_settings.dart'; + import 'package:paperless_mobile/features/settings/cubit/application_settings_cubit.dart'; +import 'package:paperless_mobile/features/settings/view/widgets/user_settings_builder.dart'; import 'package:paperless_mobile/generated/l10n/app_localizations.dart'; import 'package:provider/provider.dart'; @@ -22,42 +25,48 @@ class VerifyIdentityPage extends StatelessWidget { backgroundColor: Theme.of(context).colorScheme.background, title: Text(S.of(context)!.verifyYourIdentity), ), - body: Column( - mainAxisAlignment: MainAxisAlignment.spaceBetween, - children: [ - Text(S.of(context)!.useTheConfiguredBiometricFactorToAuthenticate) - .paddedSymmetrically(horizontal: 16), - const Icon( - Icons.fingerprint, - size: 96, - ), - Wrap( - alignment: WrapAlignment.spaceBetween, - runAlignment: WrapAlignment.spaceBetween, - runSpacing: 8, - spacing: 8, + body: UserSettingsBuilder( + builder: (context, settings) { + if (settings == null) { + return const SizedBox.shrink(); + } + return Column( + mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [ - TextButton( - onPressed: () => _logout(context), - child: Text( - S.of(context)!.disconnect, - style: TextStyle( - color: Theme.of(context).colorScheme.error, + Text(S + .of(context)! + .useTheConfiguredBiometricFactorToAuthenticate) + .paddedSymmetrically(horizontal: 16), + const Icon( + Icons.fingerprint, + size: 96, + ), + Wrap( + alignment: WrapAlignment.spaceBetween, + runAlignment: WrapAlignment.spaceBetween, + runSpacing: 8, + spacing: 8, + children: [ + TextButton( + onPressed: () => _logout(context), + child: Text( + S.of(context)!.disconnect, + style: TextStyle( + color: Theme.of(context).colorScheme.error, + ), + ), ), - ), - ), - ElevatedButton( - onPressed: () => context - .read() - .restoreSessionState(context - .read() - .state - .isLocalAuthenticationEnabled), - child: Text(S.of(context)!.verifyIdentity), - ), + ElevatedButton( + onPressed: () => context + .read() + .restoreSessionState(), + child: Text(S.of(context)!.verifyIdentity), + ), + ], + ).padded(16), ], - ).padded(16), - ], + ); + }, ), ), ); diff --git a/lib/features/login/cubit/authentication_cubit.dart b/lib/features/login/cubit/authentication_cubit.dart index 06fab40..2cf409a 100644 --- a/lib/features/login/cubit/authentication_cubit.dart +++ b/lib/features/login/cubit/authentication_cubit.dart @@ -1,17 +1,22 @@ +import 'dart:convert'; + import 'package:dio/dio.dart'; +import 'package:hive_flutter/adapters.dart'; import 'package:hydrated_bloc/hydrated_bloc.dart'; import 'package:json_annotation/json_annotation.dart'; import 'package:paperless_api/paperless_api.dart'; +import 'package:paperless_mobile/core/config/hive/hive_config.dart'; import 'package:paperless_mobile/core/security/session_manager.dart'; import 'package:paperless_mobile/features/login/model/authentication_information.dart'; import 'package:paperless_mobile/features/login/model/client_certificate.dart'; import 'package:paperless_mobile/features/login/model/user_credentials.model.dart'; import 'package:paperless_mobile/features/login/services/authentication_service.dart'; +import 'package:paperless_mobile/features/settings/global_app_settings.dart'; +import 'package:paperless_mobile/features/settings/user_app_settings.dart'; +import 'package:flutter_secure_storage/flutter_secure_storage.dart'; part 'authentication_state.dart'; -part 'authentication_cubit.g.dart'; -class AuthenticationCubit extends Cubit - with HydratedMixin { +class AuthenticationCubit extends Cubit { final LocalAuthenticationService _localAuthService; final PaperlessAuthenticationApi _authApi; final SessionManager _dioWrapper; @@ -42,15 +47,31 @@ class AuthenticationCubit extends Cubit clientCertificate: clientCertificate, authToken: token, ); + final authInfo = AuthenticationInformation( + username: credentials.username!, + serverUrl: serverUrl, + clientCertificate: clientCertificate, + token: token, + ); + final userId = "${credentials.username}@$serverUrl"; + + // Mark logged in user as currently active user. + final globalSettings = GlobalAppSettings.boxedValue; + globalSettings.currentLoggedInUser = userId; + await globalSettings.save(); + + // Save credentials in encrypted box + final encryptedBox = await _openEncryptedBox(); + await encryptedBox.put( + userId, + authInfo, + ); + encryptedBox.close(); emit( AuthenticationState( wasLoginStored: false, - authentication: AuthenticationInformation( - serverUrl: serverUrl, - clientCertificate: clientCertificate, - token: token, - ), + authentication: authInfo, ), ); } @@ -58,24 +79,27 @@ class AuthenticationCubit extends Cubit /// /// Performs a conditional hydration based on the local authentication success. /// - Future restoreSessionState(bool promptForLocalAuthentication) async { - final json = HydratedBloc.storage.read(storageToken); - - if (json == null) { + Future restoreSessionState() async { + final globalSettings = GlobalAppSettings.boxedValue; + if (globalSettings.currentLoggedInUser == null) { // If there is nothing to restore, we can quit here. return; } - if (promptForLocalAuthentication) { + final userSettings = Hive.box(HiveBoxes.userSettings) + .get(globalSettings.currentLoggedInUser!); + + if (userSettings!.isBiometricAuthenticationEnabled) { final localAuthSuccess = await _localAuthService - .authenticateLocalUser("Authenticate to log back in"); + .authenticateLocalUser("Authenticate to log back in"); //TODO: INTL if (localAuthSuccess) { - hydrate(); - if (state.isAuthenticated) { + final authentication = await _readAuthenticationFromEncryptedBox( + globalSettings.currentLoggedInUser!); + if (authentication != null) { _dioWrapper.updateSettings( - clientCertificate: state.authentication!.clientCertificate, - authToken: state.authentication!.token, - baseUrl: state.authentication!.serverUrl, + clientCertificate: authentication.clientCertificate, + authToken: authentication.token, + baseUrl: authentication.serverUrl, ); return emit( AuthenticationState( @@ -86,44 +110,62 @@ class AuthenticationCubit extends Cubit ); } } else { - hydrate(); return emit( AuthenticationState( wasLoginStored: true, wasLocalAuthenticationSuccessful: false, - authentication: state.authentication, + authentication: null, ), ); } } else { - hydrate(); - if (state.isAuthenticated) { + final authentication = await _readAuthenticationFromEncryptedBox( + globalSettings.currentLoggedInUser!); + if (authentication != null) { _dioWrapper.updateSettings( - clientCertificate: state.authentication!.clientCertificate, - authToken: state.authentication!.token, - baseUrl: state.authentication!.serverUrl, + clientCertificate: authentication.clientCertificate, + authToken: authentication.token, + baseUrl: authentication.serverUrl, ); - final authState = AuthenticationState( - authentication: state.authentication!, - wasLoginStored: true, + emit( + AuthenticationState( + authentication: authentication, + wasLoginStored: true, + ), ); - return emit(authState); } else { return emit(AuthenticationState.initial); } } } + Future _readAuthenticationFromEncryptedBox( + String userId) { + return _openEncryptedBox().then((box) => box.get(userId)); + } + + Future> _openEncryptedBox() async { + const secureStorage = FlutterSecureStorage(); + final encryptionKeyString = await secureStorage.read(key: 'key'); + if (encryptionKeyString == null) { + final key = Hive.generateSecureKey(); + + await secureStorage.write( + key: 'key', + value: base64UrlEncode(key), + ); + } + final key = await secureStorage.read(key: 'key'); + final encryptionKeyUint8List = base64Url.decode(key!); + return await Hive.openBox( + HiveBoxes.vault, + encryptionCipher: HiveAesCipher(encryptionKeyUint8List), + ); + } + Future logout() async { - await clear(); + await Hive.box(HiveBoxes.authentication).clear(); _dioWrapper.resetSettings(); emit(AuthenticationState.initial); } - - @override - AuthenticationState? fromJson(Map json) => - AuthenticationState.fromJson(json); - - @override - Map? toJson(AuthenticationState state) => state.toJson(); } diff --git a/lib/features/login/cubit/authentication_state.dart b/lib/features/login/cubit/authentication_state.dart index 918e5a8..cce28bb 100644 --- a/lib/features/login/cubit/authentication_state.dart +++ b/lib/features/login/cubit/authentication_state.dart @@ -12,6 +12,7 @@ class AuthenticationState { ); bool get isAuthenticated => authentication != null; + AuthenticationState({ required this.wasLoginStored, this.wasLocalAuthenticationSuccessful, @@ -31,9 +32,4 @@ class AuthenticationState { this.wasLocalAuthenticationSuccessful, ); } - - factory AuthenticationState.fromJson(Map json) => - _$AuthenticationStateFromJson(json); - - Map toJson() => _$AuthenticationStateToJson(this); } diff --git a/lib/features/login/model/authentication_information.dart b/lib/features/login/model/authentication_information.dart index d8b1efc..7c1c192 100644 --- a/lib/features/login/model/authentication_information.dart +++ b/lib/features/login/model/authentication_information.dart @@ -1,40 +1,32 @@ +import 'package:hive/hive.dart'; import 'package:json_annotation/json_annotation.dart'; +import 'package:paperless_mobile/core/config/hive/hive_config.dart'; import 'package:paperless_mobile/features/login/model/client_certificate.dart'; part 'authentication_information.g.dart'; -@JsonSerializable() +@HiveType(typeId: HiveTypeIds.authentication) class AuthenticationInformation { - final String? token; - final String serverUrl; - final ClientCertificate? clientCertificate; + @HiveField(0) + String? token; + + @HiveField(1) + String serverUrl; + + @HiveField(2) + ClientCertificate? clientCertificate; + + @HiveField(3) + String username; AuthenticationInformation({ - this.token, + required this.username, required this.serverUrl, + this.token, this.clientCertificate, }); bool get isValid { return serverUrl.isNotEmpty && (token?.isNotEmpty ?? false); } - - AuthenticationInformation copyWith({ - String? token, - String? serverUrl, - ClientCertificate? clientCertificate, - bool removeClientCertificate = false, - }) { - return AuthenticationInformation( - token: token ?? this.token, - serverUrl: serverUrl ?? this.serverUrl, - clientCertificate: clientCertificate ?? - (removeClientCertificate ? null : this.clientCertificate), - ); - } - - factory AuthenticationInformation.fromJson(Map json) => - _$AuthenticationInformationFromJson(json); - - Map toJson() => _$AuthenticationInformationToJson(this); } diff --git a/lib/features/login/model/client_certificate.dart b/lib/features/login/model/client_certificate.dart index 9b4eac2..a4b77d3 100644 --- a/lib/features/login/model/client_certificate.dart +++ b/lib/features/login/model/client_certificate.dart @@ -1,13 +1,20 @@ import 'dart:convert'; import 'dart:typed_data'; +import 'package:hive_flutter/adapters.dart'; +import 'package:paperless_mobile/core/config/hive/hive_config.dart'; import 'package:paperless_mobile/core/type/types.dart'; +part 'client_certificate.g.dart'; + +@HiveType(typeId: HiveTypeIds.clientCertificate) class ClientCertificate { static const bytesKey = 'bytes'; static const passphraseKey = 'passphrase'; + @HiveField(0) final Uint8List bytes; + @HiveField(1) final String? passphrase; ClientCertificate({required this.bytes, this.passphrase}); diff --git a/lib/features/settings/global_app_settings.dart b/lib/features/settings/global_app_settings.dart new file mode 100644 index 0000000..963add1 --- /dev/null +++ b/lib/features/settings/global_app_settings.dart @@ -0,0 +1,36 @@ +import 'package:flutter/material.dart'; +import 'package:hive/hive.dart'; +import 'package:paperless_mobile/core/config/hive/hive_config.dart'; +import 'package:paperless_mobile/features/settings/model/color_scheme_option.dart'; + +part 'global_app_settings.g.dart'; + +@HiveType(typeId: HiveTypeIds.globalSettings) +class GlobalAppSettings with ChangeNotifier, HiveObjectMixin { + @HiveField(0) + String preferredLocaleSubtag; + + @HiveField(1) + ThemeMode preferredThemeMode; + + @HiveField(2) + ColorSchemeOption preferredColorSchemeOption; + + @HiveField(3) + bool showOnboarding; + + @HiveField(4) + String? currentLoggedInUser; + + GlobalAppSettings({ + required this.preferredLocaleSubtag, + this.preferredThemeMode = ThemeMode.system, + this.preferredColorSchemeOption = ColorSchemeOption.classic, + this.showOnboarding = true, + this.currentLoggedInUser, + }); + + static GlobalAppSettings get boxedValue => + Hive.box(HiveBoxes.globalSettings) + .get(HiveBoxSingleValueKey.value)!; +} diff --git a/lib/features/settings/model/color_scheme_option.dart b/lib/features/settings/model/color_scheme_option.dart index 6bd92e3..d1d1327 100644 --- a/lib/features/settings/model/color_scheme_option.dart +++ b/lib/features/settings/model/color_scheme_option.dart @@ -1,4 +1,12 @@ +import 'package:hive_flutter/adapters.dart'; +import 'package:paperless_mobile/core/config/hive/hive_config.dart'; + +part 'color_scheme_option.g.dart'; + +@HiveType(typeId: HiveTypeIds.colorSchemeOption) enum ColorSchemeOption { + @HiveField(0) classic, + @HiveField(1) dynamic; } diff --git a/lib/features/settings/user_app_settings.dart b/lib/features/settings/user_app_settings.dart new file mode 100644 index 0000000..92ed637 --- /dev/null +++ b/lib/features/settings/user_app_settings.dart @@ -0,0 +1,16 @@ +import 'package:flutter/material.dart'; +import 'package:hive/hive.dart'; +import 'package:paperless_mobile/core/config/hive/hive_config.dart'; +import 'package:paperless_mobile/features/settings/model/color_scheme_option.dart'; + +part 'user_app_settings.g.dart'; + +@HiveType(typeId: HiveTypeIds.userSettings) +class UserAppSettings with HiveObjectMixin { + @HiveField(0) + bool isBiometricAuthenticationEnabled; + + UserAppSettings({ + this.isBiometricAuthenticationEnabled = false, + }); +} diff --git a/lib/features/settings/view/dialogs/account_settings_dialog.dart b/lib/features/settings/view/dialogs/account_settings_dialog.dart index 52a7109..cc6d548 100644 --- a/lib/features/settings/view/dialogs/account_settings_dialog.dart +++ b/lib/features/settings/view/dialogs/account_settings_dialog.dart @@ -9,6 +9,7 @@ import 'package:paperless_mobile/core/repository/saved_view_repository.dart'; import 'package:paperless_mobile/core/widgets/hint_card.dart'; import 'package:paperless_mobile/extensions/flutter_extensions.dart'; import 'package:paperless_mobile/features/login/cubit/authentication_cubit.dart'; +import 'package:paperless_mobile/features/settings/global_app_settings.dart'; import 'package:paperless_mobile/features/settings/cubit/application_settings_cubit.dart'; import 'package:paperless_mobile/generated/l10n/app_localizations.dart'; @@ -81,7 +82,7 @@ class AccountSettingsDialog extends StatelessWidget { Future _onLogout(BuildContext context) async { try { await context.read().logout(); - await context.read().clear(); + await context.read(); await context.read().clear(); await context.read().clear(); await HydratedBloc.storage.clear(); diff --git a/lib/features/settings/view/settings_page.dart b/lib/features/settings/view/settings_page.dart index 2e02498..ba53bc8 100644 --- a/lib/features/settings/view/settings_page.dart +++ b/lib/features/settings/view/settings_page.dart @@ -2,10 +2,12 @@ import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:paperless_mobile/core/bloc/paperless_server_information_cubit.dart'; import 'package:paperless_mobile/core/bloc/paperless_server_information_state.dart'; +import 'package:paperless_mobile/features/settings/global_app_settings.dart'; import 'package:paperless_mobile/features/settings/cubit/application_settings_cubit.dart'; import 'package:paperless_mobile/features/settings/view/pages/application_settings_page.dart'; import 'package:paperless_mobile/features/settings/view/pages/security_settings_page.dart'; import 'package:paperless_mobile/generated/l10n/app_localizations.dart'; +import 'package:provider/provider.dart'; class SettingsPage extends StatelessWidget { const SettingsPage({super.key}); @@ -68,10 +70,7 @@ class SettingsPage extends StatelessWidget { Navigator.push( context, MaterialPageRoute( - builder: (context) => BlocProvider.value( - value: context.read(), - child: page, - ), + builder: (context) => page, maintainState: true, ), ); diff --git a/lib/features/settings/view/widgets/biometric_authentication_setting.dart b/lib/features/settings/view/widgets/biometric_authentication_setting.dart index fc06e64..ca02e14 100644 --- a/lib/features/settings/view/widgets/biometric_authentication_setting.dart +++ b/lib/features/settings/view/widgets/biometric_authentication_setting.dart @@ -1,17 +1,27 @@ import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:hive_flutter/adapters.dart'; +import 'package:paperless_mobile/core/config/hive/hive_config.dart'; +import 'package:paperless_mobile/features/login/services/authentication_service.dart'; import 'package:paperless_mobile/features/settings/cubit/application_settings_cubit.dart'; +import 'package:paperless_mobile/features/settings/global_app_settings.dart'; +import 'package:paperless_mobile/features/settings/user_app_settings.dart'; +import 'package:paperless_mobile/features/settings/view/widgets/user_settings_builder.dart'; import 'package:paperless_mobile/generated/l10n/app_localizations.dart'; +import 'package:provider/provider.dart'; class BiometricAuthenticationSetting extends StatelessWidget { const BiometricAuthenticationSetting({super.key}); @override Widget build(BuildContext context) { - return BlocBuilder( + return UserSettingsBuilder( builder: (context, settings) { + if (settings == null) { + return const SizedBox.shrink(); + } return SwitchListTile( - value: settings.isLocalAuthenticationEnabled, + value: settings.isBiometricAuthenticationEnabled, title: Text(S.of(context)!.biometricAuthentication), subtitle: Text(S.of(context)!.authenticateOnAppStart), onChanged: (val) async { @@ -19,12 +29,14 @@ class BiometricAuthenticationSetting extends StatelessWidget { S.of(context)!.authenticateToToggleBiometricAuthentication( val ? 'enable' : 'disable', ); - await context - .read() - .setIsBiometricAuthenticationEnabled( - val, - localizedReason: localizedReason, - ); + + final isAuthenticated = await context + .read() + .authenticateLocalUser(localizedReason); + if (isAuthenticated) { + settings.isBiometricAuthenticationEnabled = val; + settings.save(); + } }, ); }, diff --git a/lib/features/settings/view/widgets/color_scheme_option_setting.dart b/lib/features/settings/view/widgets/color_scheme_option_setting.dart index beb2dd5..3686668 100644 --- a/lib/features/settings/view/widgets/color_scheme_option_setting.dart +++ b/lib/features/settings/view/widgets/color_scheme_option_setting.dart @@ -2,11 +2,15 @@ import 'dart:io'; import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:hive_flutter/adapters.dart'; import 'package:paperless_mobile/constants.dart'; +import 'package:paperless_mobile/core/config/hive/hive_config.dart'; import 'package:paperless_mobile/core/translation/color_scheme_option_localization_mapper.dart'; import 'package:paperless_mobile/core/widgets/hint_card.dart'; import 'package:paperless_mobile/features/settings/cubit/application_settings_cubit.dart'; +import 'package:paperless_mobile/features/settings/global_app_settings.dart'; import 'package:paperless_mobile/features/settings/model/color_scheme_option.dart'; +import 'package:paperless_mobile/features/settings/view/widgets/global_settings_builder.dart'; import 'package:paperless_mobile/features/settings/view/widgets/radio_settings_dialog.dart'; import 'package:paperless_mobile/generated/l10n/app_localizations.dart'; @@ -15,7 +19,7 @@ class ColorSchemeOptionSetting extends StatelessWidget { @override Widget build(BuildContext context) { - return BlocBuilder( + return GlobalSettingsBuilder( builder: (context, settings) { return ListTile( title: Text(S.of(context)!.colors), @@ -25,7 +29,7 @@ class ColorSchemeOptionSetting extends StatelessWidget { settings.preferredColorSchemeOption, ), ), - onTap: () => showDialog( + onTap: () => showDialog( context: context, builder: (_) => RadioSettingsDialog( titleText: S.of(context)!.colors, @@ -50,17 +54,13 @@ class ColorSchemeOptionSetting extends StatelessWidget { hintIcon: Icons.warning_amber, ) : null, - initialValue: context - .read() - .state - .preferredColorSchemeOption, + initialValue: settings.preferredColorSchemeOption, ), ).then( (value) { if (value != null) { - context - .read() - .setColorSchemeOption(value); + settings.preferredColorSchemeOption = value; + settings.save(); } }, ), diff --git a/lib/features/settings/view/widgets/global_settings_builder.dart b/lib/features/settings/view/widgets/global_settings_builder.dart new file mode 100644 index 0000000..9727943 --- /dev/null +++ b/lib/features/settings/view/widgets/global_settings_builder.dart @@ -0,0 +1,25 @@ +import 'package:flutter/material.dart'; +import 'package:flutter/src/widgets/framework.dart'; +import 'package:flutter/src/widgets/placeholder.dart'; +import 'package:hive_flutter/adapters.dart'; +import 'package:paperless_mobile/core/config/hive/hive_config.dart'; +import 'package:paperless_mobile/features/settings/global_app_settings.dart'; + +class GlobalSettingsBuilder extends StatelessWidget { + + final Widget Function(BuildContext context, GlobalAppSettings settings) + builder; + const GlobalSettingsBuilder({super.key, required this.builder}); + + @override + Widget build(BuildContext context) { + return ValueListenableBuilder( + valueListenable: + Hive.box(HiveBoxes.globalSettings).listenable(), + builder: (context, value, _) { + final settings = value.get(HiveBoxSingleValueKey.value)!; + return builder(context, settings); + }, + ); + } +} diff --git a/lib/features/settings/view/widgets/language_selection_setting.dart b/lib/features/settings/view/widgets/language_selection_setting.dart index 38966b4..23a9a35 100644 --- a/lib/features/settings/view/widgets/language_selection_setting.dart +++ b/lib/features/settings/view/widgets/language_selection_setting.dart @@ -1,8 +1,11 @@ import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:hive_flutter/adapters.dart'; import 'package:paperless_mobile/features/settings/cubit/application_settings_cubit.dart'; +import 'package:paperless_mobile/features/settings/global_app_settings.dart'; import 'package:paperless_mobile/features/settings/view/widgets/radio_settings_dialog.dart'; import 'package:paperless_mobile/generated/l10n/app_localizations.dart'; +import 'package:paperless_mobile/features/settings/view/widgets/global_settings_builder.dart'; class LanguageSelectionSetting extends StatefulWidget { const LanguageSelectionSetting({super.key}); @@ -24,7 +27,7 @@ class _LanguageSelectionSettingState extends State { @override Widget build(BuildContext context) { - return BlocBuilder( + return GlobalSettingsBuilder( builder: (context, settings) { return ListTile( title: Text(S.of(context)!.language), @@ -62,14 +65,12 @@ class _LanguageSelectionSettingState extends State { label: _languageOptions['pl']! + "*", ) ], - initialValue: context - .read() - .state - .preferredLocaleSubtag, + initialValue: settings.preferredLocaleSubtag, ), ).then((value) { if (value != null) { - context.read().setLocale(value); + settings.preferredLocaleSubtag = value; + settings.save(); } }), ); diff --git a/lib/features/settings/view/widgets/theme_mode_setting.dart b/lib/features/settings/view/widgets/theme_mode_setting.dart index a7d8667..4fff3ec 100644 --- a/lib/features/settings/view/widgets/theme_mode_setting.dart +++ b/lib/features/settings/view/widgets/theme_mode_setting.dart @@ -1,6 +1,7 @@ import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:paperless_mobile/features/settings/cubit/application_settings_cubit.dart'; +import 'package:paperless_mobile/features/settings/view/widgets/global_settings_builder.dart'; import 'package:paperless_mobile/features/settings/view/widgets/radio_settings_dialog.dart'; import 'package:paperless_mobile/generated/l10n/app_localizations.dart'; @@ -9,7 +10,7 @@ class ThemeModeSetting extends StatelessWidget { @override Widget build(BuildContext context) { - return BlocBuilder( + return GlobalSettingsBuilder( builder: (context, settings) { return ListTile( title: Text(S.of(context)!.appearance), @@ -19,10 +20,7 @@ class ThemeModeSetting extends StatelessWidget { context: context, builder: (_) => RadioSettingsDialog( titleText: S.of(context)!.appearance, - initialValue: context - .read() - .state - .preferredThemeMode, + initialValue: settings.preferredThemeMode, options: [ RadioOption( value: ThemeMode.system, @@ -40,7 +38,8 @@ class ThemeModeSetting extends StatelessWidget { ), ).then((value) { if (value != null) { - context.read().setThemeMode(value); + settings.preferredThemeMode = value; + settings.save(); } }), ); diff --git a/lib/features/settings/view/widgets/user_settings_builder.dart b/lib/features/settings/view/widgets/user_settings_builder.dart new file mode 100644 index 0000000..5497390 --- /dev/null +++ b/lib/features/settings/view/widgets/user_settings_builder.dart @@ -0,0 +1,37 @@ +import 'package:flutter/material.dart'; +import 'package:hive_flutter/adapters.dart'; +import 'package:paperless_mobile/core/config/hive/hive_config.dart'; +import 'package:paperless_mobile/features/settings/global_app_settings.dart'; +import 'package:paperless_mobile/features/settings/user_app_settings.dart'; + +class UserSettingsBuilder extends StatelessWidget { + final Widget Function( + BuildContext context, + UserAppSettings? settings, + ) builder; + + const UserSettingsBuilder({ + super.key, + required this.builder, + }); + + @override + Widget build(BuildContext context) { + return ValueListenableBuilder>( + valueListenable: + Hive.box(HiveBoxes.userSettings).listenable(), + builder: (context, value, _) { + final currentUser = + Hive.box(HiveBoxes.globalSettings) + .get(HiveBoxSingleValueKey.value) + ?.currentLoggedInUser; + if (currentUser != null) { + final settings = value.get(currentUser); + return builder(context, settings); + } else { + return builder(context, null); + } + }, + ); + } +} diff --git a/lib/main.dart b/lib/main.dart index 5b0c07d..17201a7 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -8,6 +8,7 @@ import 'package:flutter_cache_manager/flutter_cache_manager.dart' as cm; import 'package:flutter_displaymode/flutter_displaymode.dart'; import 'package:flutter_localizations/flutter_localizations.dart'; import 'package:flutter_native_splash/flutter_native_splash.dart'; +import 'package:hive_flutter/adapters.dart'; import 'package:hydrated_bloc/hydrated_bloc.dart'; import 'package:intl/date_symbol_data_local.dart'; @@ -18,6 +19,7 @@ import 'package:paperless_api/paperless_api.dart'; import 'package:paperless_mobile/core/bloc/bloc_changes_observer.dart'; import 'package:paperless_mobile/core/bloc/connectivity_cubit.dart'; import 'package:paperless_mobile/core/bloc/paperless_server_information_cubit.dart'; +import 'package:paperless_mobile/core/config/hive/hive_config.dart'; import 'package:paperless_mobile/core/interceptor/dio_http_error_interceptor.dart'; import 'package:paperless_mobile/core/interceptor/language_header.interceptor.dart'; import 'package:paperless_mobile/core/notifier/document_changed_notifier.dart'; @@ -33,7 +35,10 @@ import 'package:paperless_mobile/features/login/cubit/authentication_cubit.dart' import 'package:paperless_mobile/features/login/services/authentication_service.dart'; import 'package:paperless_mobile/features/login/view/login_page.dart'; import 'package:paperless_mobile/features/notifications/services/local_notification_service.dart'; +import 'package:paperless_mobile/features/settings/global_app_settings.dart'; import 'package:paperless_mobile/features/settings/cubit/application_settings_cubit.dart'; +import 'package:paperless_mobile/features/settings/user_app_settings.dart'; +import 'package:paperless_mobile/features/settings/view/widgets/global_settings_builder.dart'; import 'package:paperless_mobile/features/sharing/share_intent_queue.dart'; import 'package:paperless_mobile/features/tasks/cubit/task_status_cubit.dart'; import 'package:paperless_mobile/generated/l10n/app_localizations.dart'; @@ -45,10 +50,35 @@ import 'package:provider/provider.dart'; import 'package:receive_sharing_intent/receive_sharing_intent.dart'; import 'package:dynamic_color/dynamic_color.dart'; +String get defaultPreferredLocaleSubtag { + String preferredLocale = Platform.localeName.split("_").first; + if (!S.supportedLocales + .any((locale) => locale.languageCode == preferredLocale)) { + preferredLocale = 'en'; + } + return preferredLocale; +} + +Future _initHive() async { + await Hive.initFlutter(); + registerHiveAdapters(); + final globalSettingsBox = + await Hive.openBox(HiveBoxes.globalSettings); + if (!globalSettingsBox.containsKey(HiveBoxSingleValueKey.value)) { + await globalSettingsBox.put( + HiveBoxSingleValueKey.value, + GlobalAppSettings(preferredLocaleSubtag: defaultPreferredLocaleSubtag), + ); + } +} + void main() async { - Bloc.observer = BlocChangesObserver(); + await _initHive(); final widgetsBinding = WidgetsFlutterBinding.ensureInitialized(); + final globalSettings = Hive.box(HiveBoxes.globalSettings) + .get(HiveBoxSingleValueKey.value)!; + await findSystemLocale(); packageInfo = await PackageInfo.fromPlatform(); if (Platform.isAndroid) { @@ -70,11 +100,10 @@ void main() async { storageDirectory: hiveDir, ); - final appSettingsCubit = ApplicationSettingsCubit(localAuthService); FlutterNativeSplash.preserve(widgetsBinding: widgetsBinding); final languageHeaderInterceptor = LanguageHeaderInterceptor( - appSettingsCubit.state.preferredLocaleSubtag, + globalSettings.preferredLocaleSubtag, ); // Manages security context, required for self signed client certificates final sessionManager = SessionManager([ @@ -108,9 +137,11 @@ void main() async { authApi, sessionManager, ); - await authCubit.restoreSessionState( - appSettingsCubit.state.isLocalAuthenticationEnabled, - ); + + if (globalSettings.currentLoggedInUser != null) { + await authCubit + .restoreSessionState(); + } if (authCubit.state.isAuthenticated) { final auth = authCubit.state.authentication!; @@ -125,8 +156,10 @@ void main() async { await localNotificationService.initialize(); //Update language header in interceptor on language change. - appSettingsCubit.stream.listen((event) => languageHeaderInterceptor - .preferredLocaleSubtag = event.preferredLocaleSubtag); + globalSettings.addListener( + () => languageHeaderInterceptor.preferredLocaleSubtag = + globalSettings.preferredLocaleSubtag, + ); runApp( MultiProvider( @@ -165,8 +198,6 @@ void main() async { providers: [ BlocProvider.value(value: authCubit), BlocProvider.value(value: connectivityCubit), - BlocProvider.value( - value: appSettingsCubit), ], child: const PaperlessMobileEntrypoint(), ), @@ -196,7 +227,7 @@ class _PaperlessMobileEntrypointState extends State { ), ), ], - child: BlocBuilder( + child: GlobalSettingsBuilder( builder: (context, settings) { return DynamicColorBuilder( builder: (lightDynamic, darkDynamic) { diff --git a/pubspec.lock b/pubspec.lock index 785563d..6ccb921 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -681,6 +681,54 @@ packages: url: "https://pub.dev" source: hosted version: "2.0.7" + flutter_secure_storage: + dependency: "direct main" + description: + name: flutter_secure_storage + sha256: "98352186ee7ad3639ccc77ad7924b773ff6883076ab952437d20f18a61f0a7c5" + url: "https://pub.dev" + source: hosted + version: "8.0.0" + flutter_secure_storage_linux: + dependency: transitive + description: + name: flutter_secure_storage_linux + sha256: "0912ae29a572230ad52d8a4697e5518d7f0f429052fd51df7e5a7952c7efe2a3" + url: "https://pub.dev" + source: hosted + version: "1.1.3" + flutter_secure_storage_macos: + dependency: transitive + description: + name: flutter_secure_storage_macos + sha256: "083add01847fc1c80a07a08e1ed6927e9acd9618a35e330239d4422cd2a58c50" + url: "https://pub.dev" + source: hosted + version: "3.0.0" + flutter_secure_storage_platform_interface: + dependency: transitive + description: + name: flutter_secure_storage_platform_interface + sha256: b3773190e385a3c8a382007893d678ae95462b3c2279e987b55d140d3b0cb81b + url: "https://pub.dev" + source: hosted + version: "1.0.1" + flutter_secure_storage_web: + dependency: transitive + description: + name: flutter_secure_storage_web + sha256: "42938e70d4b872e856e678c423cc0e9065d7d294f45bc41fc1981a4eb4beaffe" + url: "https://pub.dev" + source: hosted + version: "1.1.1" + flutter_secure_storage_windows: + dependency: transitive + description: + name: flutter_secure_storage_windows + sha256: fc2910ec9b28d60598216c29ea763b3a96c401f0ce1d13cdf69ccb0e5c93c3ee + url: "https://pub.dev" + source: hosted + version: "2.0.0" flutter_staggered_grid_view: dependency: "direct main" description: @@ -784,6 +832,22 @@ packages: url: "https://pub.dev" source: hosted version: "2.2.3" + hive_flutter: + dependency: "direct main" + description: + name: hive_flutter + sha256: dca1da446b1d808a51689fb5d0c6c9510c0a2ba01e22805d492c73b68e33eecc + url: "https://pub.dev" + source: hosted + version: "1.1.0" + hive_generator: + dependency: "direct dev" + description: + name: hive_generator + sha256: "65998cc4d2cd9680a3d9709d893d2f6bb15e6c1f92626c3f1fa650b4b3281521" + url: "https://pub.dev" + source: hosted + version: "2.0.0" html: dependency: transitive description: diff --git a/pubspec.yaml b/pubspec.yaml index 8fb0abe..20fd082 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -93,6 +93,8 @@ dependencies: in_app_review: ^2.0.6 freezed_annotation: ^2.2.0 animations: ^2.0.7 + hive_flutter: ^1.1.0 + flutter_secure_storage: ^8.0.0 dev_dependencies: integration_test: @@ -108,6 +110,7 @@ dev_dependencies: dart_code_metrics: ^5.4.0 auto_route_generator: ^5.0.3 freezed: ^2.3.2 + hive_generator: ^2.0.0 # For information on the generic Dart part of this file, see the # following page: https://dart.dev/tools/pub/pubspec