diff --git a/lib/core/config/hive/hive_config.dart b/lib/core/config/hive/hive_config.dart index 7802e42..afa52ff 100644 --- a/lib/core/config/hive/hive_config.dart +++ b/lib/core/config/hive/hive_config.dart @@ -2,16 +2,19 @@ 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/login/model/user_account.dart'; +import 'package:paperless_mobile/features/login/model/user_credentials.dart'; +import 'package:paperless_mobile/features/settings/model/global_settings.dart'; import 'package:paperless_mobile/features/settings/model/color_scheme_option.dart'; -import 'package:paperless_mobile/features/settings/user_app_settings.dart'; +import 'package:paperless_mobile/features/settings/model/user_settings.dart'; class HiveBoxes { HiveBoxes._(); static const globalSettings = 'globalSettings'; static const userSettings = 'userSettings'; static const authentication = 'authentication'; - static const vault = 'vault'; + static const userCredentials = 'userCredentials'; + static const userAccount = 'userAccount'; } class HiveTypeIds { @@ -22,18 +25,26 @@ class HiveTypeIds { static const colorSchemeOption = 3; static const authentication = 4; static const clientCertificate = 5; -} - -class HiveBoxSingleValueKey { - HiveBoxSingleValueKey._(); - static const value = 'value'; + static const userCredentials = 6; + static const userAccount = 7; } void registerHiveAdapters() { Hive.registerAdapter(ColorSchemeOptionAdapter()); Hive.registerAdapter(ThemeModeAdapter()); - Hive.registerAdapter(GlobalAppSettingsAdapter()); - Hive.registerAdapter(UserAppSettingsAdapter()); + Hive.registerAdapter(GlobalSettingsAdapter()); Hive.registerAdapter(AuthenticationInformationAdapter()); Hive.registerAdapter(ClientCertificateAdapter()); + Hive.registerAdapter(UserSettingsAdapter()); + Hive.registerAdapter(UserCredentialsAdapter()); + Hive.registerAdapter(UserAccountAdapter()); +} + +extension HiveSingleValueBox on Box { + static const _valueKey = 'SINGLE_VALUE'; + bool get hasValue => containsKey(_valueKey); + + T? getValue() => get(_valueKey); + + Future setValue(T value) => put(_valueKey, value); } diff --git a/lib/core/repository/label_repository.dart b/lib/core/repository/label_repository.dart index 18febc7..b497bb2 100644 --- a/lib/core/repository/label_repository.dart +++ b/lib/core/repository/label_repository.dart @@ -38,8 +38,7 @@ class LabelRepository extends HydratedCubit { Future createTag(Tag object) async { final created = await _api.saveTag(object); - final updatedState = {...state.tags} - ..putIfAbsent(created.id!, () => created); + final updatedState = {...state.tags}..putIfAbsent(created.id!, () => created); emit(state.copyWith(tags: updatedState)); return created; } @@ -63,8 +62,7 @@ class LabelRepository extends HydratedCubit { Future> findAllTags([Iterable? ids]) async { final tags = await _api.getTags(ids); - final updatedState = {...state.tags} - ..addEntries(tags.map((e) => MapEntry(e.id!, e))); + final updatedState = {...state.tags}..addEntries(tags.map((e) => MapEntry(e.id!, e))); emit(state.copyWith(tags: updatedState)); return tags; } @@ -78,16 +76,14 @@ class LabelRepository extends HydratedCubit { Future createCorrespondent(Correspondent correspondent) async { final created = await _api.saveCorrespondent(correspondent); - final updatedState = {...state.correspondents} - ..putIfAbsent(created.id!, () => created); + final updatedState = {...state.correspondents}..putIfAbsent(created.id!, () => created); emit(state.copyWith(correspondents: updatedState)); return created; } Future deleteCorrespondent(Correspondent correspondent) async { await _api.deleteCorrespondent(correspondent); - final updatedState = {...state.correspondents} - ..removeWhere((k, v) => k == correspondent.id); + final updatedState = {...state.correspondents}..removeWhere((k, v) => k == correspondent.id); emit(state.copyWith(correspondents: updatedState)); return correspondent.id!; @@ -104,8 +100,7 @@ class LabelRepository extends HydratedCubit { return null; } - Future> findAllCorrespondents( - [Iterable? ids]) async { + Future> findAllCorrespondents([Iterable? ids]) async { final correspondents = await _api.getCorrespondents(ids); final updatedState = {...state.correspondents} ..addEntries(correspondents.map((e) => MapEntry(e.id!, e))); @@ -116,8 +111,7 @@ class LabelRepository extends HydratedCubit { Future updateCorrespondent(Correspondent correspondent) async { final updated = await _api.updateCorrespondent(correspondent); - final updatedState = {...state.correspondents} - ..update(updated.id!, (_) => updated); + final updatedState = {...state.correspondents}..update(updated.id!, (_) => updated); emit(state.copyWith(correspondents: updatedState)); return updated; @@ -125,16 +119,14 @@ class LabelRepository extends HydratedCubit { Future createDocumentType(DocumentType documentType) async { final created = await _api.saveDocumentType(documentType); - final updatedState = {...state.documentTypes} - ..putIfAbsent(created.id!, () => created); + final updatedState = {...state.documentTypes}..putIfAbsent(created.id!, () => created); emit(state.copyWith(documentTypes: updatedState)); return created; } Future deleteDocumentType(DocumentType documentType) async { await _api.deleteDocumentType(documentType); - final updatedState = {...state.documentTypes} - ..removeWhere((k, v) => k == documentType.id); + final updatedState = {...state.documentTypes}..removeWhere((k, v) => k == documentType.id); emit(state.copyWith(documentTypes: updatedState)); return documentType.id!; } @@ -149,8 +141,7 @@ class LabelRepository extends HydratedCubit { return null; } - Future> findAllDocumentTypes( - [Iterable? ids]) async { + Future> findAllDocumentTypes([Iterable? ids]) async { final documentTypes = await _api.getDocumentTypes(ids); final updatedState = {...state.documentTypes} ..addEntries(documentTypes.map((e) => MapEntry(e.id!, e))); @@ -160,24 +151,21 @@ class LabelRepository extends HydratedCubit { Future updateDocumentType(DocumentType documentType) async { final updated = await _api.updateDocumentType(documentType); - final updatedState = {...state.documentTypes} - ..update(updated.id!, (_) => updated); + final updatedState = {...state.documentTypes}..update(updated.id!, (_) => updated); emit(state.copyWith(documentTypes: updatedState)); return updated; } Future createStoragePath(StoragePath storagePath) async { final created = await _api.saveStoragePath(storagePath); - final updatedState = {...state.storagePaths} - ..putIfAbsent(created.id!, () => created); + final updatedState = {...state.storagePaths}..putIfAbsent(created.id!, () => created); emit(state.copyWith(storagePaths: updatedState)); return created; } Future deleteStoragePath(StoragePath storagePath) async { await _api.deleteStoragePath(storagePath); - final updatedState = {...state.storagePaths} - ..removeWhere((k, v) => k == storagePath.id); + final updatedState = {...state.storagePaths}..removeWhere((k, v) => k == storagePath.id); emit(state.copyWith(storagePaths: updatedState)); return storagePath.id!; } @@ -192,8 +180,7 @@ class LabelRepository extends HydratedCubit { return null; } - Future> findAllStoragePaths( - [Iterable? ids]) async { + Future> findAllStoragePaths([Iterable? ids]) async { final storagePaths = await _api.getStoragePaths(ids); final updatedState = {...state.storagePaths} ..addEntries(storagePaths.map((e) => MapEntry(e.id!, e))); @@ -203,8 +190,7 @@ class LabelRepository extends HydratedCubit { Future updateStoragePath(StoragePath storagePath) async { final updated = await _api.updateStoragePath(storagePath); - final updatedState = {...state.storagePaths} - ..update(updated.id!, (_) => updated); + final updatedState = {...state.storagePaths}..update(updated.id!, (_) => updated); emit(state.copyWith(storagePaths: updatedState)); return updated; } @@ -217,6 +203,12 @@ class LabelRepository extends HydratedCubit { return super.close(); } + @override + Future clear() async { + await super.clear(); + emit(const LabelRepositoryState()); + } + @override LabelRepositoryState? fromJson(Map json) { return LabelRepositoryState.fromJson(json); diff --git a/lib/core/repository/saved_view_repository.dart b/lib/core/repository/saved_view_repository.dart index 78d6b8e..b84cdda 100644 --- a/lib/core/repository/saved_view_repository.dart +++ b/lib/core/repository/saved_view_repository.dart @@ -27,8 +27,7 @@ class SavedViewRepository extends HydratedCubit { Future create(SavedView object) async { final created = await _api.save(object); - final updatedState = {...state.savedViews} - ..putIfAbsent(created.id!, () => created); + final updatedState = {...state.savedViews}..putIfAbsent(created.id!, () => created); emit(state.copyWith(savedViews: updatedState)); return created; } @@ -43,8 +42,7 @@ class SavedViewRepository extends HydratedCubit { Future find(int id) async { final found = await _api.find(id); if (found != null) { - final updatedState = {...state.savedViews} - ..update(id, (_) => found, ifAbsent: () => found); + final updatedState = {...state.savedViews}..update(id, (_) => found, ifAbsent: () => found); emit(state.copyWith(savedViews: updatedState)); } return found; @@ -68,6 +66,12 @@ class SavedViewRepository extends HydratedCubit { return super.close(); } + @override + Future clear() async { + await super.clear(); + emit(const SavedViewRepositoryState()); + } + @override SavedViewRepositoryState? fromJson(Map json) { return SavedViewRepositoryState.fromJson(json); diff --git a/lib/core/service/connectivity_status_service.dart b/lib/core/service/connectivity_status_service.dart index 0dc1cf1..83d309d 100644 --- a/lib/core/service/connectivity_status_service.dart +++ b/lib/core/service/connectivity_status_service.dart @@ -32,8 +32,7 @@ class ConnectivityStatusServiceImpl implements ConnectivityStatusService { @override Future isConnectedToInternet() async { - return _hasActiveInternetConnection( - await (Connectivity().checkConnectivity())); + return _hasActiveInternetConnection(await (Connectivity().checkConnectivity())); } @override @@ -72,11 +71,10 @@ class ConnectivityStatusServiceImpl implements ConnectivityStatusService { return ReachabilityStatus.unknown; } try { - SessionManager manager = - SessionManager([ServerReachabilityErrorInterceptor()]) - ..updateSettings(clientCertificate: clientCertificate) - ..client.options.connectTimeout = const Duration(seconds: 5) - ..client.options.receiveTimeout = const Duration(seconds: 5); + SessionManager manager = SessionManager([ServerReachabilityErrorInterceptor()]) + ..updateSettings(clientCertificate: clientCertificate) + ..client.options.connectTimeout = const Duration(seconds: 5) + ..client.options.receiveTimeout = const Duration(seconds: 5); final response = await manager.client.get('$serverAddress/api/'); if (response.statusCode == 200) { @@ -84,8 +82,7 @@ class ConnectivityStatusServiceImpl implements ConnectivityStatusService { } return ReachabilityStatus.notReachable; } on DioError catch (error) { - if (error.type == DioErrorType.unknown && - error.error is ReachabilityStatus) { + if (error.type == DioErrorType.unknown && error.error is ReachabilityStatus) { return error.error as ReachabilityStatus; } } on TlsException catch (error) { diff --git a/lib/core/service/status_service.dart b/lib/core/service/status_service.dart index dce014d..cd662f6 100644 --- a/lib/core/service/status_service.dart +++ b/lib/core/service/status_service.dart @@ -9,11 +9,12 @@ import 'package:paperless_mobile/core/bloc/document_status_cubit.dart'; import 'package:paperless_mobile/core/model/document_processing_status.dart'; import 'package:paperless_mobile/features/login/model/authentication_information.dart'; import 'package:paperless_mobile/constants.dart'; +import 'package:paperless_mobile/features/login/model/user_credentials.dart'; import 'package:web_socket_channel/io.dart'; abstract class StatusService { - Future startListeningBeforeDocumentUpload(String httpUrl, - AuthenticationInformation credentials, String documentFileName); + Future startListeningBeforeDocumentUpload( + String httpUrl, UserCredentials credentials, String documentFileName); } class WebSocketStatusService implements StatusService { @@ -25,7 +26,7 @@ class WebSocketStatusService implements StatusService { @override Future startListeningBeforeDocumentUpload( String httpUrl, - AuthenticationInformation credentials, + UserCredentials credentials, String documentFileName, ) async { // socket = await WebSocket.connect( @@ -57,7 +58,7 @@ class LongPollingStatusService implements StatusService { @override Future startListeningBeforeDocumentUpload( String httpUrl, - AuthenticationInformation credentials, + UserCredentials credentials, String documentFileName, ) async { // final today = DateTime.now(); diff --git a/lib/features/app_drawer/view/app_drawer.dart b/lib/features/app_drawer/view/app_drawer.dart index 2399d19..295db95 100644 --- a/lib/features/app_drawer/view/app_drawer.dart +++ b/lib/features/app_drawer/view/app_drawer.dart @@ -2,9 +2,9 @@ import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:flutter_svg/flutter_svg.dart'; import 'package:paperless_mobile/constants.dart'; +import 'package:paperless_mobile/core/bloc/paperless_server_information_cubit.dart'; import 'package:paperless_mobile/core/widgets/paperless_logo.dart'; import 'package:paperless_mobile/extensions/flutter_extensions.dart'; -import 'package:paperless_mobile/features/settings/cubit/application_settings_cubit.dart'; import 'package:paperless_mobile/features/settings/view/settings_page.dart'; import 'package:paperless_mobile/generated/l10n/app_localizations.dart'; @@ -42,8 +42,7 @@ class AppDrawer extends StatelessWidget { leading: const Icon(Icons.bug_report_outlined), title: Text(S.of(context)!.reportABug), onTap: () { - launchUrlString( - 'https://github.com/astubenbord/paperless-mobile/issues/new'); + launchUrlString('https://github.com/astubenbord/paperless-mobile/issues/new'); }, ), ListTile( @@ -69,7 +68,10 @@ class AppDrawer extends StatelessWidget { ), onTap: () => Navigator.of(context).push( MaterialPageRoute( - builder: (context) => const SettingsPage(), + builder: (_) => BlocProvider.value( + value: context.read(), + child: const SettingsPage(), + ), ), ), ), diff --git a/lib/features/app_intro/application_intro_slideshow.dart b/lib/features/app_intro/application_intro_slideshow.dart index 6891d64..cdaf9e5 100644 --- a/lib/features/app_intro/application_intro_slideshow.dart +++ b/lib/features/app_intro/application_intro_slideshow.dart @@ -10,8 +10,7 @@ class ApplicationIntroSlideshow extends StatefulWidget { const ApplicationIntroSlideshow({super.key}); @override - State createState() => - _ApplicationIntroSlideshowState(); + State createState() => _ApplicationIntroSlideshowState(); } //TODO: INTL ALL @@ -28,7 +27,9 @@ class _ApplicationIntroSlideshowState extends State { showDoneButton: true, next: Text(S.of(context)!.next), done: Text(S.of(context)!.done), - onDone: () => Navigator.pop(context), + onDone: () { + Navigator.pop(context); + }, dotsDecorator: DotsDecorator( color: Theme.of(context).colorScheme.onBackground, activeColor: Theme.of(context).colorScheme.primary, 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 c4d31c8..07b4628 100644 --- a/lib/features/document_details/view/widgets/document_download_button.dart +++ b/lib/features/document_details/view/widgets/document_download_button.dart @@ -5,8 +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/features/settings/model/global_settings.dart'; import 'package:paperless_mobile/generated/l10n/app_localizations.dart'; import 'package:paperless_mobile/helpers/message_helpers.dart'; @@ -44,9 +43,8 @@ class _DocumentDownloadButtonState extends State { width: 16, ) : const Icon(Icons.download), - onPressed: widget.document != null && widget.enabled - ? () => _onDownload(widget.document!) - : null, + onPressed: + widget.document != null && widget.enabled ? () => _onDownload(widget.document!) : null, ).paddedOnly(right: 4); } @@ -70,7 +68,7 @@ class _DocumentDownloadButtonState extends State { setState(() => _isDownloadPending = true); await context.read().downloadDocument( downloadOriginal: downloadOriginal, - locale: context.read().preferredLocaleSubtag, + locale: context.read().preferredLocaleSubtag, ); // showSnackBar(context, S.of(context)!.documentSuccessfullyDownloaded); } on PaperlessServerException catch (error, stackTrace) { diff --git a/lib/features/document_search/view/sliver_search_bar.dart b/lib/features/document_search/view/sliver_search_bar.dart index 7748c8b..52caa7f 100644 --- a/lib/features/document_search/view/sliver_search_bar.dart +++ b/lib/features/document_search/view/sliver_search_bar.dart @@ -3,10 +3,10 @@ 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/core/delegate/customizable_sliver_persistent_header_delegate.dart'; -import 'package:paperless_mobile/core/widgets/material/search/m3_search_bar.dart' - as s; +import 'package:paperless_mobile/core/widgets/material/search/m3_search_bar.dart' as s; import 'package:paperless_mobile/features/document_search/view/document_search_page.dart'; import 'package:paperless_mobile/features/settings/view/dialogs/account_settings_dialog.dart'; +import 'package:paperless_mobile/features/settings/view/manage_accounts_page.dart'; import 'package:paperless_mobile/generated/l10n/app_localizations.dart'; class SliverSearchBar extends StatelessWidget { @@ -37,8 +37,7 @@ class SliverSearchBar extends StatelessWidget { onPressed: Scaffold.of(context).openDrawer, ), trailingIcon: IconButton( - icon: BlocBuilder( + icon: BlocBuilder( builder: (context, state) { return CircleAvatar( child: Text(state.information?.userInitials ?? ''), @@ -48,7 +47,10 @@ class SliverSearchBar extends StatelessWidget { onPressed: () { showDialog( context: context, - builder: (context) => const AccountSettingsDialog(), + builder: (_) => BlocProvider.value( + value: context.read(), + child: const ManageAccountsPage(), + ), ); }, ), diff --git a/lib/features/documents/view/widgets/items/document_detailed_item.dart b/lib/features/documents/view/widgets/items/document_detailed_item.dart index 023e0f1..8b2e8a3 100644 --- a/lib/features/documents/view/widgets/items/document_detailed_item.dart +++ b/lib/features/documents/view/widgets/items/document_detailed_item.dart @@ -46,9 +46,8 @@ class DocumentDetailedItem extends DocumentItem { padding.bottom - kBottomNavigationBarHeight - kToolbarHeight; - final maxHeight = highlights != null - ? min(600.0, availableHeight) - : min(500.0, availableHeight); + final maxHeight = + highlights != null ? min(600.0, availableHeight) : min(500.0, availableHeight); return Card( color: isSelected ? Theme.of(context).colorScheme.inversePrimary : null, child: InkWell( diff --git a/lib/features/home/view/home_page.dart b/lib/features/home/view/home_page.dart index fb8d185..6ae9ce6 100644 --- a/lib/features/home/view/home_page.dart +++ b/lib/features/home/view/home_page.dart @@ -25,6 +25,7 @@ import 'package:paperless_mobile/features/inbox/cubit/inbox_cubit.dart'; import 'package:paperless_mobile/features/inbox/view/pages/inbox_page.dart'; import 'package:paperless_mobile/features/labels/cubit/label_cubit.dart'; import 'package:paperless_mobile/features/labels/view/pages/labels_page.dart'; +import 'package:paperless_mobile/features/login/cubit/authentication_cubit.dart'; import 'package:paperless_mobile/features/notifications/services/local_notification_service.dart'; import 'package:paperless_mobile/features/saved_view/cubit/saved_view_cubit.dart'; import 'package:paperless_mobile/features/sharing/share_intent_queue.dart'; @@ -185,6 +186,7 @@ class _HomePageState extends State with WidgetsBindingObserver { @override Widget build(BuildContext context) { + final userId = context.watch().state.userId; final destinations = [ RouteDescription( icon: const Icon(Icons.description_outlined), @@ -232,19 +234,20 @@ class _HomePageState extends State with WidgetsBindingObserver { ]; final routes = [ MultiBlocProvider( + key: ValueKey(userId), providers: [ BlocProvider( create: (context) => DocumentsCubit( context.read(), context.read(), context.read(), - ), + )..reload(), ), BlocProvider( create: (context) => SavedViewCubit( context.read(), context.read(), - ), + )..reload(), ), ], child: const DocumentsPage(), @@ -254,6 +257,7 @@ class _HomePageState extends State with WidgetsBindingObserver { child: const ScannerPage(), ), MultiBlocProvider( + key: ValueKey(userId), providers: [ BlocProvider( create: (context) => LabelCubit(context.read()), @@ -266,12 +270,12 @@ class _HomePageState extends State with WidgetsBindingObserver { child: const InboxPage(), ), ]; + return MultiBlocListener( listeners: [ BlocListener( //Only re-initialize data if the connectivity changed from not connected to connected - listenWhen: (previous, current) => - current == ConnectivityState.connected, + listenWhen: (previous, current) => current == ConnectivityState.connected, listener: (context, state) { _initializeData(context); }, @@ -280,9 +284,7 @@ class _HomePageState extends State with WidgetsBindingObserver { listener: (context, state) { if (state.task != null) { // Handle local notifications on task change (only when app is running for now). - context - .read() - .notifyTaskChanged(state.task!); + context.read().notifyTaskChanged(state.task!); } }, ), @@ -295,9 +297,7 @@ class _HomePageState extends State with WidgetsBindingObserver { children: [ NavigationRail( labelType: NavigationRailLabelType.all, - destinations: destinations - .map((e) => e.toNavigationRailDestination()) - .toList(), + destinations: destinations.map((e) => e.toNavigationRailDestination()).toList(), selectedIndex: _currentIndex, onDestinationSelected: _onNavigationChanged, ), @@ -315,8 +315,7 @@ class _HomePageState extends State with WidgetsBindingObserver { elevation: 4.0, selectedIndex: _currentIndex, onDestinationSelected: _onNavigationChanged, - destinations: - destinations.map((e) => e.toNavigationDestination()).toList(), + destinations: destinations.map((e) => e.toNavigationDestination()).toList(), ), body: routes[_currentIndex], ); diff --git a/lib/features/home/view/widget/verify_identity_page.dart b/lib/features/home/view/widget/verify_identity_page.dart index 1a4035a..0eb1c3f 100644 --- a/lib/features/home/view/widget/verify_identity_page.dart +++ b/lib/features/home/view/widget/verify_identity_page.dart @@ -5,9 +5,8 @@ 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/model/global_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'; @@ -33,9 +32,7 @@ class VerifyIdentityPage extends StatelessWidget { return Column( mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [ - Text(S - .of(context)! - .useTheConfiguredBiometricFactorToAuthenticate) + Text(S.of(context)!.useTheConfiguredBiometricFactorToAuthenticate) .paddedSymmetrically(horizontal: 16), const Icon( Icons.fingerprint, @@ -57,9 +54,7 @@ class VerifyIdentityPage extends StatelessWidget { ), ), ElevatedButton( - onPressed: () => context - .read() - .restoreSessionState(), + onPressed: () => context.read().restoreSessionState(), child: Text(S.of(context)!.verifyIdentity), ), ], diff --git a/lib/features/inbox/cubit/inbox_cubit.dart b/lib/features/inbox/cubit/inbox_cubit.dart index 69efa7a..8ab72af 100644 --- a/lib/features/inbox/cubit/inbox_cubit.dart +++ b/lib/features/inbox/cubit/inbox_cubit.dart @@ -13,8 +13,7 @@ import 'package:paperless_mobile/features/paged_document_view/cubit/document_pag part 'inbox_cubit.g.dart'; part 'inbox_state.dart'; -class InboxCubit extends HydratedCubit - with DocumentPagingBlocMixin { +class InboxCubit extends HydratedCubit with DocumentPagingBlocMixin { final LabelRepository _labelRepository; final PaperlessDocumentsApi _documentsApi; @@ -37,10 +36,7 @@ class InboxCubit extends HydratedCubit this, onDeleted: remove, onUpdated: (document) { - if (document.tags - .toSet() - .intersection(state.inboxTags.toSet()) - .isEmpty) { + if (document.tags.toSet().intersection(state.inboxTags.toSet()).isEmpty) { remove(document); emit(state.copyWith(itemsInInboxCount: state.itemsInInboxCount - 1)); } else { @@ -76,28 +72,32 @@ class InboxCubit extends HydratedCubit /// Fetches inbox tag ids and loads the inbox items (documents). /// Future loadInbox() async { - debugPrint("Initializing inbox..."); - final inboxTags = await _labelRepository.findAllTags().then( - (tags) => tags.where((t) => t.isInboxTag).map((t) => t.id!), - ); + if (!isClosed) { + debugPrint("Initializing inbox..."); - if (inboxTags.isEmpty) { - // no inbox tags = no inbox items. - return emit( - state.copyWith( - hasLoaded: true, - value: [], - inboxTags: [], + final inboxTags = await _labelRepository.findAllTags().then( + (tags) => tags.where((t) => t.isInboxTag).map((t) => t.id!), + ); + + if (inboxTags.isEmpty) { + // no inbox tags = no inbox items. + return emit( + state.copyWith( + hasLoaded: true, + value: [], + inboxTags: [], + ), + ); + } + + emit(state.copyWith(inboxTags: inboxTags)); + updateFilter( + filter: DocumentFilter( + sortField: SortField.added, + tags: IdsTagsQuery.fromIds(inboxTags), ), ); } - emit(state.copyWith(inboxTags: inboxTags)); - updateFilter( - filter: DocumentFilter( - sortField: SortField.added, - tags: IdsTagsQuery.fromIds(inboxTags), - ), - ); } /// @@ -133,8 +133,7 @@ class InboxCubit extends HydratedCubit /// from the inbox. /// Future> removeFromInbox(DocumentModel document) async { - final tagsToRemove = - document.tags.toSet().intersection(state.inboxTags.toSet()); + final tagsToRemove = document.tags.toSet().intersection(state.inboxTags.toSet()); final updatedTags = {...document.tags}..removeAll(tagsToRemove); final updatedDocument = await api.update( @@ -188,8 +187,8 @@ class InboxCubit extends HydratedCubit Future assignAsn(DocumentModel document) async { if (document.archiveSerialNumber == null) { final int asn = await _documentsApi.findNextAsn(); - final updatedDocument = await _documentsApi - .update(document.copyWith(archiveSerialNumber: () => asn)); + final updatedDocument = + await _documentsApi.update(document.copyWith(archiveSerialNumber: () => asn)); replace(updatedDocument); } diff --git a/lib/features/login/cubit/authentication_cubit.dart b/lib/features/login/cubit/authentication_cubit.dart index 2cf409a..4b08951 100644 --- a/lib/features/login/cubit/authentication_cubit.dart +++ b/lib/features/login/cubit/authentication_cubit.dart @@ -1,18 +1,23 @@ import 'dart:convert'; +import 'dart:typed_data'; -import 'package:dio/dio.dart'; +import 'package:equatable/equatable.dart'; +import 'package:flutter/widgets.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/interceptor/dio_http_error_interceptor.dart'; +import 'package:paperless_mobile/core/repository/label_repository.dart'; +import 'package:paperless_mobile/core/repository/saved_view_repository.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/model/login_form_credentials.dart'; +import 'package:paperless_mobile/features/login/model/user_account.dart'; +import 'package:paperless_mobile/features/login/model/user_credentials.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:paperless_mobile/features/settings/model/global_settings.dart'; +import 'package:paperless_mobile/features/settings/model/user_settings.dart'; import 'package:flutter_secure_storage/flutter_secure_storage.dart'; part 'authentication_state.dart'; @@ -20,15 +25,21 @@ class AuthenticationCubit extends Cubit { final LocalAuthenticationService _localAuthService; final PaperlessAuthenticationApi _authApi; final SessionManager _dioWrapper; + final LabelRepository _labelRepository; + final SavedViewRepository _savedViewRepository; + final PaperlessServerStatsApi _serverStatsApi; AuthenticationCubit( this._localAuthService, this._authApi, this._dioWrapper, - ) : super(AuthenticationState.initial); + this._labelRepository, + this._savedViewRepository, + this._serverStatsApi, + ) : super(const AuthenticationState()); Future login({ - required UserCredentials credentials, + required LoginFormCredentials credentials, required String serverUrl, ClientCertificate? clientCertificate, }) async { @@ -47,107 +58,239 @@ 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"; + // If it is first time login, create settings for this user. + final userSettingsBox = Hive.box(HiveBoxes.userSettings); + final userAccountBox = Hive.box(HiveBoxes.userAccount); + if (!userSettingsBox.containsKey(userId)) { + userSettingsBox.put(userId, UserSettings()); + } + final fullName = await _fetchFullName(); + + if (!userAccountBox.containsKey(userId)) { + userAccountBox.put( + userId, + UserAccount( + serverUrl: serverUrl, + username: credentials.username!, + fullName: fullName, + ), + ); + } + // Mark logged in user as currently active user. - final globalSettings = GlobalAppSettings.boxedValue; + final globalSettings = GlobalSettings.boxedValue; globalSettings.currentLoggedInUser = userId; - await globalSettings.save(); + globalSettings.save(); // Save credentials in encrypted box - final encryptedBox = await _openEncryptedBox(); - await encryptedBox.put( + final userCredentialsBox = await _getUserCredentialsBox(); + await userCredentialsBox.put( userId, - authInfo, - ); - encryptedBox.close(); - - emit( - AuthenticationState( - wasLoginStored: false, - authentication: authInfo, + UserCredentials( + token: token, + clientCertificate: clientCertificate, ), ); + userCredentialsBox.close(); + emit( + AuthenticationState( + isAuthenticated: true, + username: credentials.username, + userId: userId, + fullName: fullName, + //TODO: Query ui settings with full name and add as parameter here... + ), + ); + } + + /// Switches to another account if it exists. + Future switchAccount(String userId) async { + final globalSettings = GlobalSettings.boxedValue; + if (globalSettings.currentLoggedInUser == userId) { + return; + } + final userAccountBox = Hive.box(HiveBoxes.userAccount); + final userSettingsBox = Hive.box(HiveBoxes.userSettings); + + if (!userSettingsBox.containsKey(userId)) { + debugPrint("User $userId not yet registered."); + return; + } + + final userSettings = userSettingsBox.get(userId)!; + final account = userAccountBox.get(userId)!; + + if (userSettings.isBiometricAuthenticationEnabled) { + final authenticated = + await _localAuthService.authenticateLocalUser("Authenticate to switch your account."); + if (!authenticated) { + debugPrint("User unable to authenticate."); + return; + } + } + + final credentialsBox = await _getUserCredentialsBox(); + if (!credentialsBox.containsKey(userId)) { + await credentialsBox.close(); + debugPrint("Invalid authentication for $userId"); + return; + } + + final credentials = credentialsBox.get(userId); + await _resetExternalState(); + + _dioWrapper.updateSettings( + authToken: credentials!.token, + clientCertificate: credentials.clientCertificate, + serverInformation: PaperlessServerInformationModel(), + baseUrl: account.serverUrl, + ); + + globalSettings.currentLoggedInUser = userId; + await globalSettings.save(); + await _reloadRepositories(); + emit( + AuthenticationState( + isAuthenticated: true, + username: account.username, + fullName: account.fullName, + userId: userId, + ), + ); + } + + Future addAccount({ + required LoginFormCredentials credentials, + required String serverUrl, + ClientCertificate? clientCertificate, + required bool enableBiometricAuthentication, + }) async { + assert(credentials.password != null && credentials.username != null); + final userId = "${credentials.username}@$serverUrl"; + final userAccountsBox = Hive.box(HiveBoxes.userAccount); + final userSettingsBox = Hive.box(HiveBoxes.userSettings); + + if (userAccountsBox.containsKey(userId)) { + throw Exception("User already exists"); + } + // Creates a parallel session to get token and disposes of resources after. + final sessionManager = SessionManager([ + DioHttpErrorInterceptor(), + ]); + sessionManager.updateSettings( + clientCertificate: clientCertificate, + baseUrl: serverUrl, + ); + final authApi = PaperlessAuthenticationApiImpl(sessionManager.client); + + final token = await authApi.login( + username: credentials.username!, + password: credentials.password!, + ); + sessionManager.resetSettings(); + await userSettingsBox.put( + userId, + UserSettings( + isBiometricAuthenticationEnabled: enableBiometricAuthentication, + ), + ); + final fullName = await _fetchFullName(); + await userAccountsBox.put( + userId, + UserAccount( + serverUrl: serverUrl, + username: credentials.username!, + fullName: fullName, + ), + ); + + final userCredentialsBox = await _getUserCredentialsBox(); + await userCredentialsBox.put( + userId, + UserCredentials( + token: token, + clientCertificate: clientCertificate, + ), + ); + await userCredentialsBox.close(); + return userId; + } + + Future removeAccount(String userId) async { + final globalSettings = GlobalSettings.boxedValue; + final currentUser = globalSettings.currentLoggedInUser; + final userAccountBox = Hive.box(HiveBoxes.userAccount); + final userCredentialsBox = await _getUserCredentialsBox(); + final userSettingsBox = Hive.box(HiveBoxes.userSettings); + + await userAccountBox.delete(userId); + await userCredentialsBox.delete(userId); + await userSettingsBox.delete(userId); + + if (currentUser == userId) { + return logout(); + } } /// /// Performs a conditional hydration based on the local authentication success. /// Future restoreSessionState() async { - final globalSettings = GlobalAppSettings.boxedValue; - if (globalSettings.currentLoggedInUser == null) { + final globalSettings = GlobalSettings.boxedValue; + final userId = globalSettings.currentLoggedInUser; + if (userId == null) { // If there is nothing to restore, we can quit here. return; } - final userSettings = Hive.box(HiveBoxes.userSettings) - .get(globalSettings.currentLoggedInUser!); + final userSettings = Hive.box(HiveBoxes.userSettings).get(userId)!; + final userAccount = Hive.box(HiveBoxes.userAccount).get(userId)!; - if (userSettings!.isBiometricAuthenticationEnabled) { - final localAuthSuccess = await _localAuthService - .authenticateLocalUser("Authenticate to log back in"); //TODO: INTL - if (localAuthSuccess) { - final authentication = await _readAuthenticationFromEncryptedBox( - globalSettings.currentLoggedInUser!); - if (authentication != null) { - _dioWrapper.updateSettings( - clientCertificate: authentication.clientCertificate, - authToken: authentication.token, - baseUrl: authentication.serverUrl, - ); - return emit( - AuthenticationState( - wasLoginStored: true, - authentication: state.authentication, - wasLocalAuthenticationSuccessful: true, - ), - ); - } - } else { - return emit( - AuthenticationState( - wasLoginStored: true, - wasLocalAuthenticationSuccessful: false, - authentication: null, - ), - ); + if (userSettings.isBiometricAuthenticationEnabled) { + final localAuthSuccess = + await _localAuthService.authenticateLocalUser("Authenticate to log back in"); //TODO: INTL + if (!localAuthSuccess) { + emit(const AuthenticationState(showBiometricAuthenticationScreen: true)); + return; } + } + final userCredentialsBox = await _getUserCredentialsBox(); + + final authentication = userCredentialsBox.get(globalSettings.currentLoggedInUser!); + if (authentication != null) { + _dioWrapper.updateSettings( + clientCertificate: authentication.clientCertificate, + authToken: authentication.token, + baseUrl: userAccount.serverUrl, + serverInformation: PaperlessServerInformationModel(), + ); + emit( + AuthenticationState( + isAuthenticated: true, + showBiometricAuthenticationScreen: false, + username: userAccount.username, + ), + ); } else { - final authentication = await _readAuthenticationFromEncryptedBox( - globalSettings.currentLoggedInUser!); - if (authentication != null) { - _dioWrapper.updateSettings( - clientCertificate: authentication.clientCertificate, - authToken: authentication.token, - baseUrl: authentication.serverUrl, - ); - emit( - AuthenticationState( - authentication: authentication, - wasLoginStored: true, - ), - ); - } else { - return emit(AuthenticationState.initial); - } + throw Exception("User should be authenticated but no authentication information was found."); } } - Future _readAuthenticationFromEncryptedBox( - String userId) { - return _openEncryptedBox().then((box) => box.get(userId)); + Future logout() async { + await _resetExternalState(); + final globalSettings = GlobalSettings.boxedValue; + globalSettings + ..currentLoggedInUser = null + ..save(); + emit(const AuthenticationState()); } - Future> _openEncryptedBox() async { + Future _getEncryptedBoxKey() async { const secureStorage = FlutterSecureStorage(); - final encryptionKeyString = await secureStorage.read(key: 'key'); - if (encryptionKeyString == null) { + if (!await secureStorage.containsKey(key: 'key')) { final key = Hive.generateSecureKey(); await secureStorage.write( @@ -155,17 +298,40 @@ class AuthenticationCubit extends Cubit { value: base64UrlEncode(key), ); } - final key = await secureStorage.read(key: 'key'); - final encryptionKeyUint8List = base64Url.decode(key!); - return await Hive.openBox( - HiveBoxes.vault, - encryptionCipher: HiveAesCipher(encryptionKeyUint8List), + final key = (await secureStorage.read(key: 'key'))!; + return base64Decode(key); + } + + Future> _getUserCredentialsBox() async { + final keyBytes = await _getEncryptedBoxKey(); + return Hive.openBox( + HiveBoxes.userCredentials, + encryptionCipher: HiveAesCipher(keyBytes), ); } - Future logout() async { - await Hive.box(HiveBoxes.authentication).clear(); + Future _resetExternalState() { _dioWrapper.resetSettings(); - emit(AuthenticationState.initial); + return Future.wait([ + HydratedBloc.storage.clear(), + _labelRepository.clear(), + _savedViewRepository.clear(), + ]); + } + + Future _reloadRepositories() { + return Future.wait([ + _labelRepository.initialize(), + _savedViewRepository.findAll(), + ]); + } + + Future _fetchFullName() async { + try { + final uiSettings = await _serverStatsApi.getUiSettings(); + return uiSettings.displayName; + } catch (error) { + return null; + } } } diff --git a/lib/features/login/cubit/authentication_state.dart b/lib/features/login/cubit/authentication_state.dart index cce28bb..cced26f 100644 --- a/lib/features/login/cubit/authentication_state.dart +++ b/lib/features/login/cubit/authentication_state.dart @@ -1,35 +1,43 @@ part of 'authentication_cubit.dart'; -@JsonSerializable() -class AuthenticationState { - final bool wasLoginStored; - @JsonKey(includeFromJson: false, includeToJson: false) - final bool? wasLocalAuthenticationSuccessful; - final AuthenticationInformation? authentication; +class AuthenticationState with EquatableMixin { + final bool showBiometricAuthenticationScreen; + final bool isAuthenticated; + final String? username; + final String? fullName; + final String? userId; - static final AuthenticationState initial = AuthenticationState( - wasLoginStored: false, - ); - - bool get isAuthenticated => authentication != null; - - AuthenticationState({ - required this.wasLoginStored, - this.wasLocalAuthenticationSuccessful, - this.authentication, + const AuthenticationState({ + this.isAuthenticated = false, + this.showBiometricAuthenticationScreen = false, + this.username, + this.fullName, + this.userId, }); AuthenticationState copyWith({ - bool? wasLoginStored, bool? isAuthenticated, - AuthenticationInformation? authentication, - bool? wasLocalAuthenticationSuccessful, + bool? showBiometricAuthenticationScreen, + String? username, + String? fullName, + String? userId, }) { return AuthenticationState( - wasLoginStored: wasLoginStored ?? this.wasLoginStored, - authentication: authentication ?? this.authentication, - wasLocalAuthenticationSuccessful: wasLocalAuthenticationSuccessful ?? - this.wasLocalAuthenticationSuccessful, + isAuthenticated: isAuthenticated ?? this.isAuthenticated, + showBiometricAuthenticationScreen: + showBiometricAuthenticationScreen ?? this.showBiometricAuthenticationScreen, + username: username ?? this.username, + fullName: fullName ?? this.fullName, + userId: userId ?? this.userId, ); } + + @override + List get props => [ + userId, + username, + fullName, + isAuthenticated, + showBiometricAuthenticationScreen, + ]; } diff --git a/lib/features/login/model/client_certificate.dart b/lib/features/login/model/client_certificate.dart index a4b77d3..e3af6df 100644 --- a/lib/features/login/model/client_certificate.dart +++ b/lib/features/login/model/client_certificate.dart @@ -3,44 +3,15 @@ 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; + Uint8List bytes; @HiveField(1) - final String? passphrase; + String? passphrase; ClientCertificate({required this.bytes, this.passphrase}); - - static ClientCertificate? nullable(Uint8List? bytes, {String? passphrase}) { - if (bytes != null) { - return ClientCertificate(bytes: bytes, passphrase: passphrase); - } - return null; - } - - JSON toJson() { - return { - bytesKey: base64Encode(bytes), - passphraseKey: passphrase, - }; - } - - ClientCertificate.fromJson(JSON json) - : bytes = base64Decode(json[bytesKey]), - passphrase = json[passphraseKey]; - - ClientCertificate copyWith({Uint8List? bytes, String? passphrase}) { - return ClientCertificate( - bytes: bytes ?? this.bytes, - passphrase: passphrase ?? this.passphrase, - ); - } } diff --git a/lib/features/login/model/client_certificate_form_model.dart b/lib/features/login/model/client_certificate_form_model.dart new file mode 100644 index 0000000..cac452d --- /dev/null +++ b/lib/features/login/model/client_certificate_form_model.dart @@ -0,0 +1,19 @@ +import 'dart:convert'; +import 'dart:typed_data'; + +class ClientCertificateFormModel { + static const bytesKey = 'bytes'; + static const passphraseKey = 'passphrase'; + + final Uint8List bytes; + final String? passphrase; + + ClientCertificateFormModel({required this.bytes, this.passphrase}); + + ClientCertificateFormModel copyWith({Uint8List? bytes, String? passphrase}) { + return ClientCertificateFormModel( + bytes: bytes ?? this.bytes, + passphrase: passphrase ?? this.passphrase, + ); + } +} diff --git a/lib/features/login/model/login_form_credentials.dart b/lib/features/login/model/login_form_credentials.dart new file mode 100644 index 0000000..b0497fc --- /dev/null +++ b/lib/features/login/model/login_form_credentials.dart @@ -0,0 +1,13 @@ +class LoginFormCredentials { + final String? username; + final String? password; + + LoginFormCredentials({this.username, this.password}); + + LoginFormCredentials copyWith({String? username, String? password}) { + return LoginFormCredentials( + username: username ?? this.username, + password: password ?? this.password, + ); + } +} diff --git a/lib/features/login/model/user_account.dart b/lib/features/login/model/user_account.dart new file mode 100644 index 0000000..4894cf6 --- /dev/null +++ b/lib/features/login/model/user_account.dart @@ -0,0 +1,20 @@ +import 'package:hive_flutter/adapters.dart'; +import 'package:paperless_mobile/core/config/hive/hive_config.dart'; + +part 'user_account.g.dart'; + +@HiveType(typeId: HiveTypeIds.userAccount) +class UserAccount { + @HiveField(0) + final String serverUrl; + @HiveField(1) + final String username; + @HiveField(2) + final String? fullName; + + UserAccount({ + required this.serverUrl, + required this.username, + this.fullName, + }); +} diff --git a/lib/features/login/model/user_credentials.dart b/lib/features/login/model/user_credentials.dart new file mode 100644 index 0000000..f2228b8 --- /dev/null +++ b/lib/features/login/model/user_credentials.dart @@ -0,0 +1,18 @@ +import 'package:hive/hive.dart'; +import 'package:paperless_mobile/core/config/hive/hive_config.dart'; +import 'package:paperless_mobile/features/login/model/client_certificate.dart'; + +part 'user_credentials.g.dart'; + +@HiveType(typeId: HiveTypeIds.userCredentials) +class UserCredentials extends HiveObject { + @HiveField(0) + final String token; + @HiveField(1) + final ClientCertificate? clientCertificate; + + UserCredentials({ + required this.token, + this.clientCertificate, + }); +} diff --git a/lib/features/login/model/user_credentials.model.dart b/lib/features/login/model/user_credentials.model.dart deleted file mode 100644 index b69e3ce..0000000 --- a/lib/features/login/model/user_credentials.model.dart +++ /dev/null @@ -1,13 +0,0 @@ -class UserCredentials { - final String? username; - final String? password; - - UserCredentials({this.username, this.password}); - - UserCredentials copyWith({String? username, String? password}) { - return UserCredentials( - username: username ?? this.username, - password: password ?? this.password, - ); - } -} diff --git a/lib/features/login/view/login_page.dart b/lib/features/login/view/login_page.dart index 9bf7954..937c801 100644 --- a/lib/features/login/view/login_page.dart +++ b/lib/features/login/view/login_page.dart @@ -3,18 +3,37 @@ import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:flutter_form_builder/flutter_form_builder.dart'; import 'package:paperless_api/paperless_api.dart'; import 'package:paperless_mobile/core/type/types.dart'; +import 'package:paperless_mobile/features/app_intro/application_intro_slideshow.dart'; import 'package:paperless_mobile/features/login/cubit/authentication_cubit.dart'; +import 'package:paperless_mobile/features/login/model/client_certificate.dart'; +import 'package:paperless_mobile/features/login/model/client_certificate_form_model.dart'; +import 'package:paperless_mobile/features/login/model/login_form_credentials.dart'; import 'package:paperless_mobile/features/login/view/widgets/form_fields/client_certificate_form_field.dart'; import 'package:paperless_mobile/features/login/view/widgets/form_fields/server_address_form_field.dart'; import 'package:paperless_mobile/features/login/view/widgets/form_fields/user_credentials_form_field.dart'; import 'package:paperless_mobile/features/login/view/widgets/login_pages/server_connection_page.dart'; +import 'package:paperless_mobile/features/settings/model/global_settings.dart'; import 'package:paperless_mobile/helpers/message_helpers.dart'; import 'widgets/login_pages/server_login_page.dart'; import 'widgets/never_scrollable_scroll_behavior.dart'; class LoginPage extends StatefulWidget { - const LoginPage({Key? key}) : super(key: key); + final void Function( + BuildContext context, + String username, + String password, + String serverUrl, + ClientCertificate? clientCertificate, + ) onSubmit; + + final String submitText; + + const LoginPage({ + Key? key, + required this.onSubmit, + required this.submitText, + }) : super(key: key); @override State createState() => _LoginPageState(); @@ -46,7 +65,8 @@ class _LoginPageState extends State { ), ServerLoginPage( formBuilderKey: _formKey, - onDone: _login, + submitText: widget.submitText, + onSubmit: _login, ), ], ), @@ -58,24 +78,23 @@ class _LoginPageState extends State { FocusScope.of(context).unfocus(); if (_formKey.currentState?.saveAndValidate() ?? false) { final form = _formKey.currentState!.value; - try { - await context.read().login( - credentials: form[UserCredentialsFormField.fkCredentials], - serverUrl: form[ServerAddressFormField.fkServerAddress], - clientCertificate: - form[ClientCertificateFormField.fkClientCertificate], - ); - } on PaperlessServerException catch (error, stackTrace) { - showErrorMessage(context, error, stackTrace); - } on PaperlessValidationErrors catch (error, stackTrace) { - if (error.hasFieldUnspecificError) { - showLocalizedError(context, error.fieldUnspecificError!); - } else { - showGenericError(context, error.values.first, stackTrace); - } - } catch (unknownError, stackTrace) { - showGenericError(context, unknownError.toString(), stackTrace); + ClientCertificate? clientCert; + final clientCertFormModel = + form[ClientCertificateFormField.fkClientCertificate] as ClientCertificateFormModel?; + if (clientCertFormModel != null) { + clientCert = ClientCertificate( + bytes: clientCertFormModel.bytes, + passphrase: clientCertFormModel.passphrase, + ); } + final credentials = form[UserCredentialsFormField.fkCredentials] as LoginFormCredentials; + widget.onSubmit( + context, + credentials.username!, + credentials.password!, + form[ServerAddressFormField.fkServerAddress], + clientCert, + ); } } } diff --git a/lib/features/login/view/widgets/form_fields/client_certificate_form_field.dart b/lib/features/login/view/widgets/form_fields/client_certificate_form_field.dart index d97556b..560c478 100644 --- a/lib/features/login/view/widgets/form_fields/client_certificate_form_field.dart +++ b/lib/features/login/view/widgets/form_fields/client_certificate_form_field.dart @@ -5,6 +5,7 @@ import 'package:flutter/material.dart'; import 'package:flutter_form_builder/flutter_form_builder.dart'; import 'package:paperless_mobile/extensions/flutter_extensions.dart'; import 'package:paperless_mobile/features/login/model/client_certificate.dart'; +import 'package:paperless_mobile/features/login/model/client_certificate_form_model.dart'; import 'package:paperless_mobile/generated/l10n/app_localizations.dart'; import 'package:paperless_mobile/constants.dart'; @@ -15,23 +16,21 @@ import 'obscured_input_text_form_field.dart'; class ClientCertificateFormField extends StatefulWidget { static const fkClientCertificate = 'clientCertificate'; - final void Function(ClientCertificate? cert) onChanged; + final void Function(ClientCertificateFormModel? cert) onChanged; const ClientCertificateFormField({ Key? key, required this.onChanged, }) : super(key: key); @override - State createState() => - _ClientCertificateFormFieldState(); + State createState() => _ClientCertificateFormFieldState(); } -class _ClientCertificateFormFieldState - extends State { +class _ClientCertificateFormFieldState extends State { File? _selectedFile; @override Widget build(BuildContext context) { - return FormBuilderField( + return FormBuilderField( key: const ValueKey('login-client-cert'), onChanged: widget.onChanged, initialValue: null, @@ -46,8 +45,7 @@ class _ClientCertificateFormFieldState return null; }, builder: (field) { - final theme = - Theme.of(context).copyWith(dividerColor: Colors.transparent); //new + final theme = Theme.of(context).copyWith(dividerColor: Colors.transparent); //new return Theme( data: theme, child: ExpansionTile( @@ -124,7 +122,7 @@ class _ClientCertificateFormFieldState ); } - Future _onSelectFile(FormFieldState field) async { + Future _onSelectFile(FormFieldState field) async { FilePickerResult? result = await FilePicker.platform.pickFiles( allowMultiple: false, ); @@ -133,14 +131,13 @@ class _ClientCertificateFormFieldState setState(() { _selectedFile = file; }); - final changedValue = - field.value?.copyWith(bytes: file.readAsBytesSync()) ?? - ClientCertificate(bytes: file.readAsBytesSync()); + final changedValue = field.value?.copyWith(bytes: file.readAsBytesSync()) ?? + ClientCertificateFormModel(bytes: file.readAsBytesSync()); field.didChange(changedValue); } } - Widget _buildSelectedFileText(FormFieldState field) { + Widget _buildSelectedFileText(FormFieldState field) { if (field.value == null) { assert(_selectedFile == null); return Text( diff --git a/lib/features/login/view/widgets/form_fields/user_credentials_form_field.dart b/lib/features/login/view/widgets/form_fields/user_credentials_form_field.dart index bd32733..a70be87 100644 --- a/lib/features/login/view/widgets/form_fields/user_credentials_form_field.dart +++ b/lib/features/login/view/widgets/form_fields/user_credentials_form_field.dart @@ -2,7 +2,7 @@ import 'package:flutter/material.dart'; import 'package:flutter_form_builder/flutter_form_builder.dart'; import 'package:paperless_mobile/extensions/flutter_extensions.dart'; -import 'package:paperless_mobile/features/login/model/user_credentials.model.dart'; +import 'package:paperless_mobile/features/login/model/login_form_credentials.dart'; import 'package:paperless_mobile/features/login/view/widgets/form_fields/obscured_input_text_form_field.dart'; import 'package:paperless_mobile/generated/l10n/app_localizations.dart'; @@ -14,14 +14,13 @@ class UserCredentialsFormField extends StatefulWidget { }) : super(key: key); @override - State createState() => - _UserCredentialsFormFieldState(); + State createState() => _UserCredentialsFormFieldState(); } class _UserCredentialsFormFieldState extends State { @override Widget build(BuildContext context) { - return FormBuilderField( + return FormBuilderField( name: UserCredentialsFormField.fkCredentials, builder: (field) => AutofillGroup( child: Column( @@ -34,7 +33,7 @@ class _UserCredentialsFormFieldState extends State { autocorrect: false, onChanged: (username) => field.didChange( field.value?.copyWith(username: username) ?? - UserCredentials(username: username), + LoginFormCredentials(username: username), ), validator: (value) { if (value?.trim().isEmpty ?? true) { @@ -51,7 +50,7 @@ class _UserCredentialsFormFieldState extends State { label: S.of(context)!.password, onChanged: (password) => field.didChange( field.value?.copyWith(password: password) ?? - UserCredentials(password: password), + LoginFormCredentials(password: password), ), validator: (value) { if (value?.trim().isEmpty ?? true) { diff --git a/lib/features/login/view/widgets/login_pages/server_connection_page.dart b/lib/features/login/view/widgets/login_pages/server_connection_page.dart index 0d89fe9..c300a20 100644 --- a/lib/features/login/view/widgets/login_pages/server_connection_page.dart +++ b/lib/features/login/view/widgets/login_pages/server_connection_page.dart @@ -3,6 +3,8 @@ import 'package:flutter_form_builder/flutter_form_builder.dart'; import 'package:paperless_mobile/core/service/connectivity_status_service.dart'; import 'package:paperless_mobile/core/widgets/paperless_logo.dart'; import 'package:paperless_mobile/extensions/flutter_extensions.dart'; +import 'package:paperless_mobile/features/login/model/client_certificate.dart'; +import 'package:paperless_mobile/features/login/model/client_certificate_form_model.dart'; import 'package:paperless_mobile/features/login/model/reachability_status.dart'; import 'package:paperless_mobile/features/login/view/widgets/form_fields/client_certificate_form_field.dart'; import 'package:paperless_mobile/features/login/view/widgets/form_fields/server_address_form_field.dart'; @@ -35,9 +37,8 @@ class _ServerConnectionPageState extends State { toolbarHeight: kToolbarHeight - 4, title: Text(S.of(context)!.connectToPaperless), bottom: PreferredSize( - child: _isCheckingConnection - ? const LinearProgressIndicator() - : const SizedBox(height: 4.0), + child: + _isCheckingConnection ? const LinearProgressIndicator() : const SizedBox(height: 4.0), preferredSize: const Size.fromHeight(4.0), ), ), @@ -67,9 +68,8 @@ class _ServerConnectionPageState extends State { ), FilledButton( child: Text(S.of(context)!.continueLabel), - onPressed: _reachabilityStatus == ReachabilityStatus.reachable - ? widget.onContinue - : null, + onPressed: + _reachabilityStatus == ReachabilityStatus.reachable ? widget.onContinue : null, ), ], ), @@ -81,16 +81,16 @@ class _ServerConnectionPageState extends State { setState(() { _isCheckingConnection = true; }); - - final status = await context - .read() - .isPaperlessServerReachable( + final certForm = widget.formBuilderKey.currentState + ?.getRawValue(ClientCertificateFormField.fkClientCertificate) + as ClientCertificateFormModel?; + final status = await context.read().isPaperlessServerReachable( address ?? widget.formBuilderKey.currentState! .getRawValue(ServerAddressFormField.fkServerAddress), - widget.formBuilderKey.currentState?.getRawValue( - ClientCertificateFormField.fkClientCertificate, - ), + certForm != null + ? ClientCertificate(bytes: certForm.bytes, passphrase: certForm.passphrase) + : null, ); setState(() { _isCheckingConnection = false; diff --git a/lib/features/login/view/widgets/login_pages/server_login_page.dart b/lib/features/login/view/widgets/login_pages/server_login_page.dart index 74c6d70..3767f42 100644 --- a/lib/features/login/view/widgets/login_pages/server_login_page.dart +++ b/lib/features/login/view/widgets/login_pages/server_login_page.dart @@ -6,12 +6,14 @@ import 'package:paperless_mobile/features/login/view/widgets/form_fields/user_cr import 'package:paperless_mobile/generated/l10n/app_localizations.dart'; class ServerLoginPage extends StatefulWidget { - final Future Function() onDone; + final String submitText; + final Future Function() onSubmit; final GlobalKey formBuilderKey; const ServerLoginPage({ super.key, - required this.onDone, + required this.onSubmit, required this.formBuilderKey, + required this.submitText, }); @override @@ -23,8 +25,7 @@ class _ServerLoginPageState extends State { @override Widget build(BuildContext context) { final serverAddress = (widget.formBuilderKey.currentState - ?.getRawValue(ServerAddressFormField.fkServerAddress) - as String?) + ?.getRawValue(ServerAddressFormField.fkServerAddress) as String?) ?.replaceAll(RegExp(r'https?://'), '') ?? ''; return Scaffold( @@ -50,7 +51,7 @@ class _ServerLoginPageState extends State { FilledButton( onPressed: () async { setState(() => _isLoginLoading = true); - await widget.onDone(); + await widget.onSubmit(); setState(() => _isLoginLoading = false); }, child: Text(S.of(context)!.signIn), diff --git a/lib/features/saved_view_details/cubit/saved_view_details_cubit.dart b/lib/features/saved_view_details/cubit/saved_view_details_cubit.dart index ba2cc1c..db5a01f 100644 --- a/lib/features/saved_view_details/cubit/saved_view_details_cubit.dart +++ b/lib/features/saved_view_details/cubit/saved_view_details_cubit.dart @@ -43,12 +43,14 @@ class SavedViewDetailsCubit extends HydratedCubit _labelRepository.addListener( this, onChanged: (labels) { - emit(state.copyWith( - correspondents: labels.correspondents, - documentTypes: labels.documentTypes, - tags: labels.tags, - storagePaths: labels.storagePaths, - )); + if (!isClosed) { + emit(state.copyWith( + correspondents: labels.correspondents, + documentTypes: labels.documentTypes, + tags: labels.tags, + storagePaths: labels.storagePaths, + )); + } }, ); updateFilter(filter: savedView.toDocumentFilter()); diff --git a/lib/features/search_app_bar/view/search_app_bar.dart b/lib/features/search_app_bar/view/search_app_bar.dart index 157ad0f..d8611cf 100644 --- a/lib/features/search_app_bar/view/search_app_bar.dart +++ b/lib/features/search_app_bar/view/search_app_bar.dart @@ -2,10 +2,10 @@ 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/core/widgets/material/search/m3_search_bar.dart' - as s; +import 'package:paperless_mobile/core/widgets/material/search/m3_search_bar.dart' as s; import 'package:paperless_mobile/extensions/flutter_extensions.dart'; import 'package:paperless_mobile/features/settings/view/dialogs/account_settings_dialog.dart'; +import 'package:paperless_mobile/features/settings/view/manage_accounts_page.dart'; typedef OpenSearchCallback = void Function(BuildContext context); @@ -47,8 +47,7 @@ class _SearchAppBarState extends State { onPressed: Scaffold.of(context).openDrawer, ), trailingIcon: IconButton( - icon: BlocBuilder( + icon: BlocBuilder( builder: (context, state) { return CircleAvatar( child: Text(state.information?.userInitials ?? ''), @@ -58,7 +57,10 @@ class _SearchAppBarState extends State { onPressed: () { showDialog( context: context, - builder: (context) => const AccountSettingsDialog(), + builder: (context) => BlocProvider.value( + value: context.read(), + child: const ManageAccountsPage(), + ), ); }, ), diff --git a/lib/features/settings/cubit/application_settings_cubit.dart b/lib/features/settings/cubit/application_settings_cubit.dart deleted file mode 100644 index b68201e..0000000 --- a/lib/features/settings/cubit/application_settings_cubit.dart +++ /dev/null @@ -1,64 +0,0 @@ -import 'dart:io'; - -import 'package:flutter/material.dart'; -import 'package:hydrated_bloc/hydrated_bloc.dart'; -import 'package:json_annotation/json_annotation.dart'; -import 'package:paperless_mobile/features/login/services/authentication_service.dart'; -import 'package:paperless_mobile/features/settings/model/color_scheme_option.dart'; -import 'package:paperless_mobile/generated/l10n/app_localizations.dart'; - -part 'application_settings_cubit.g.dart'; -part 'application_settings_state.dart'; - -class ApplicationSettingsCubit extends HydratedCubit { - final LocalAuthenticationService _localAuthenticationService; - ApplicationSettingsCubit(this._localAuthenticationService) - : super(ApplicationSettingsState.defaultSettings); - - Future setLocale(String? localeSubtag) async { - final updatedSettings = state.copyWith(preferredLocaleSubtag: localeSubtag); - _updateSettings(updatedSettings); - } - - Future setIsBiometricAuthenticationEnabled( - bool isEnabled, { - required String localizedReason, - }) async { - final isActionAuthorized = await _localAuthenticationService - .authenticateLocalUser(localizedReason); - if (isActionAuthorized) { - final updatedSettings = - state.copyWith(isLocalAuthenticationEnabled: isEnabled); - _updateSettings(updatedSettings); - } - } - - void setThemeMode(ThemeMode? selectedMode) { - final updatedSettings = state.copyWith(preferredThemeMode: selectedMode); - _updateSettings(updatedSettings); - } - - void setColorSchemeOption(ColorSchemeOption schemeOption) { - final updatedSettings = - state.copyWith(preferredColorSchemeOption: schemeOption); - _updateSettings(updatedSettings); - } - - void _updateSettings(ApplicationSettingsState settings) async { - emit(settings); - } - - @override - Future clear() async { - await super.clear(); - emit(ApplicationSettingsState.defaultSettings); - } - - @override - ApplicationSettingsState? fromJson(Map json) => - ApplicationSettingsState.fromJson(json); - - @override - Map? toJson(ApplicationSettingsState state) => - state.toJson(); -} diff --git a/lib/features/settings/cubit/application_settings_state.dart b/lib/features/settings/cubit/application_settings_state.dart deleted file mode 100644 index 8a08a4b..0000000 --- a/lib/features/settings/cubit/application_settings_state.dart +++ /dev/null @@ -1,53 +0,0 @@ -part of 'application_settings_cubit.dart'; - -/// -/// State holding the current application settings such as selected language, theme mode and more. -/// -@JsonSerializable() -class ApplicationSettingsState { - static final defaultSettings = ApplicationSettingsState( - preferredLocaleSubtag: _defaultPreferredLocaleSubtag, - ); - - final bool isLocalAuthenticationEnabled; - final String preferredLocaleSubtag; - final ThemeMode preferredThemeMode; - final ColorSchemeOption preferredColorSchemeOption; - - ApplicationSettingsState({ - required this.preferredLocaleSubtag, - this.preferredThemeMode = ThemeMode.system, - this.isLocalAuthenticationEnabled = false, - this.preferredColorSchemeOption = ColorSchemeOption.classic, - }); - - Map toJson() => _$ApplicationSettingsStateToJson(this); - factory ApplicationSettingsState.fromJson(Map json) => - _$ApplicationSettingsStateFromJson(json); - - ApplicationSettingsState copyWith({ - bool? isLocalAuthenticationEnabled, - String? preferredLocaleSubtag, - ThemeMode? preferredThemeMode, - ColorSchemeOption? preferredColorSchemeOption, - }) { - return ApplicationSettingsState( - isLocalAuthenticationEnabled: - isLocalAuthenticationEnabled ?? this.isLocalAuthenticationEnabled, - preferredLocaleSubtag: - preferredLocaleSubtag ?? this.preferredLocaleSubtag, - preferredThemeMode: preferredThemeMode ?? this.preferredThemeMode, - preferredColorSchemeOption: - preferredColorSchemeOption ?? this.preferredColorSchemeOption, - ); - } - - static String get _defaultPreferredLocaleSubtag { - String preferredLocale = Platform.localeName.split("_").first; - if (!S.supportedLocales - .any((locale) => locale.languageCode == preferredLocale)) { - preferredLocale = 'en'; - } - return preferredLocale; - } -} diff --git a/lib/features/settings/global_app_settings.dart b/lib/features/settings/model/global_settings.dart similarity index 73% rename from lib/features/settings/global_app_settings.dart rename to lib/features/settings/model/global_settings.dart index 963add1..ba0ee34 100644 --- a/lib/features/settings/global_app_settings.dart +++ b/lib/features/settings/model/global_settings.dart @@ -3,10 +3,10 @@ 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'; +part 'global_settings.g.dart'; @HiveType(typeId: HiveTypeIds.globalSettings) -class GlobalAppSettings with ChangeNotifier, HiveObjectMixin { +class GlobalSettings with HiveObjectMixin { @HiveField(0) String preferredLocaleSubtag; @@ -22,7 +22,7 @@ class GlobalAppSettings with ChangeNotifier, HiveObjectMixin { @HiveField(4) String? currentLoggedInUser; - GlobalAppSettings({ + GlobalSettings({ required this.preferredLocaleSubtag, this.preferredThemeMode = ThemeMode.system, this.preferredColorSchemeOption = ColorSchemeOption.classic, @@ -30,7 +30,6 @@ class GlobalAppSettings with ChangeNotifier, HiveObjectMixin { this.currentLoggedInUser, }); - static GlobalAppSettings get boxedValue => - Hive.box(HiveBoxes.globalSettings) - .get(HiveBoxSingleValueKey.value)!; + static GlobalSettings get boxedValue => + Hive.box(HiveBoxes.globalSettings).getValue()!; } diff --git a/lib/features/settings/user_app_settings.dart b/lib/features/settings/model/user_settings.dart similarity index 54% rename from lib/features/settings/user_app_settings.dart rename to lib/features/settings/model/user_settings.dart index 92ed637..3612eb0 100644 --- a/lib/features/settings/user_app_settings.dart +++ b/lib/features/settings/model/user_settings.dart @@ -1,16 +1,14 @@ -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'; +part 'user_settings.g.dart'; @HiveType(typeId: HiveTypeIds.userSettings) -class UserAppSettings with HiveObjectMixin { +class UserSettings with HiveObjectMixin { @HiveField(0) bool isBiometricAuthenticationEnabled; - UserAppSettings({ + UserSettings({ 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 cc6d548..9bf9aae 100644 --- a/lib/features/settings/view/dialogs/account_settings_dialog.dart +++ b/lib/features/settings/view/dialogs/account_settings_dialog.dart @@ -1,18 +1,18 @@ +import 'package:collection/collection.dart'; import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:hive_flutter/adapters.dart'; import 'package:hydrated_bloc/hydrated_bloc.dart'; import 'package:paperless_api/paperless_api.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/core/repository/label_repository.dart'; -import 'package:paperless_mobile/core/repository/saved_view_repository.dart'; +import 'package:paperless_mobile/core/config/hive/hive_config.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/features/login/model/user_account.dart'; +import 'package:paperless_mobile/features/settings/view/widgets/global_settings_builder.dart'; import 'package:paperless_mobile/generated/l10n/app_localizations.dart'; - import 'package:paperless_mobile/helpers/message_helpers.dart'; class AccountSettingsDialog extends StatelessWidget { @@ -20,74 +20,95 @@ class AccountSettingsDialog extends StatelessWidget { @override Widget build(BuildContext context) { - return AlertDialog( - scrollable: true, - contentPadding: EdgeInsets.zero, - title: Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, - children: [ - Text(S.of(context)!.account), - const CloseButton(), - ], - ), - content: BlocBuilder( - builder: (context, state) { - return Column( - children: [ - ExpansionTile( - leading: CircleAvatar( - child: Text(state.information?.userInitials ?? ''), + return GlobalSettingsBuilder(builder: (context, globalSettings) { + return AlertDialog( + insetPadding: EdgeInsets.symmetric(horizontal: 24, vertical: 32), + scrollable: true, + contentPadding: EdgeInsets.zero, + title: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text(S.of(context)!.account), + const CloseButton(), + ], + ), + content: BlocBuilder( + builder: (context, state) { + return Column( + children: [ + ValueListenableBuilder( + valueListenable: Hive.box(HiveBoxes.userAccount).listenable(), + builder: (context, box, _) { + // final currentUser = globalSettings.currentLoggedInUser; + final currentUser = null; + final accountIds = + box.keys.whereNot((element) => element == currentUser).toList(); + final accounts = accountIds.map((id) => box.get(id)!).toList(); + return ExpansionTile( + leading: CircleAvatar( + child: Text(state.information?.userInitials ?? ''), + ), + title: Text(state.information?.username ?? ''), + subtitle: Text(state.information?.host ?? ''), + children: + accounts.map((account) => _buildAccountTile(account, true)).toList(), + ); + }, ), - title: Text(state.information?.username ?? ''), - subtitle: Text(state.information?.host ?? ''), - children: const [ - HintCard( - hintText: "WIP: Coming soon with multi user support!", - ), - ], - ), - const Divider(), - ListTile( - dense: true, - leading: const Icon(Icons.person_add_rounded), - title: Text(S.of(context)!.addAnotherAccount), - onTap: () {}, - ), - const Divider(), - FilledButton( - style: ButtonStyle( - backgroundColor: MaterialStatePropertyAll( - Theme.of(context).colorScheme.error, - ), + ListTile( + dense: true, + leading: const Icon(Icons.person_add_rounded), + title: Text(S.of(context)!.addAnotherAccount), + onTap: () {}, ), - child: Text( - S.of(context)!.disconnect, - style: TextStyle( - color: Theme.of(context).colorScheme.onError, + const Divider(), + FilledButton( + style: ButtonStyle( + backgroundColor: MaterialStatePropertyAll( + Theme.of(context).colorScheme.error, + ), ), - ), - onPressed: () async { - await _onLogout(context); - Navigator.of(context).maybePop(); - }, - ).padded(16), - ], - ); - }, - ), - ); + child: Text( + S.of(context)!.disconnect, + style: TextStyle( + color: Theme.of(context).colorScheme.onError, + ), + ), + onPressed: () async { + await _onLogout(context); + Navigator.of(context).maybePop(); + }, + ).padded(16), + ], + ); + }, + ), + ); + }); } Future _onLogout(BuildContext context) async { try { await context.read().logout(); - await context.read(); - await context.read().clear(); - await context.read().clear(); await HydratedBloc.storage.clear(); } on PaperlessServerException catch (error, stackTrace) { showErrorMessage(context, error, stackTrace); } } + + Widget _buildAccountTile(UserAccount account, bool isActive) { + return ListTile( + selected: isActive, + title: Text(account.username), + subtitle: Text(account.serverUrl), + leading: CircleAvatar( + child: Text((account.fullName ?? account.username) + .split(" ") + .take(2) + .map((e) => e.substring(0, 1)) + .map((e) => e.toUpperCase()) + .join(" ")), + ), + ); + } } diff --git a/lib/features/settings/view/dialogs/switch_account_dialog.dart b/lib/features/settings/view/dialogs/switch_account_dialog.dart new file mode 100644 index 0000000..7cc3ab6 --- /dev/null +++ b/lib/features/settings/view/dialogs/switch_account_dialog.dart @@ -0,0 +1,29 @@ +import 'package:flutter/material.dart'; +import 'package:paperless_mobile/core/widgets/dialog_utils/dialog_cancel_button.dart'; +import 'package:paperless_mobile/core/widgets/dialog_utils/dialog_confirm_button.dart'; +import 'package:paperless_mobile/generated/l10n/app_localizations.dart'; + +class SwitchAccountDialog extends StatelessWidget { + final String username; + final String serverUrl; + const SwitchAccountDialog({ + super.key, + required this.username, + required this.serverUrl, + }); + + @override + Widget build(BuildContext context) { + return AlertDialog( + title: Text("Switch account"), + content: Text("Do you want to switch to $serverUrl and log in as $username?"), + actions: [ + DialogConfirmButton( + style: DialogConfirmButtonStyle.danger, + label: S.of(context)!.continueLabel, //TODO: INTL change labels + ), + DialogCancelButton(), + ], + ); + } +} diff --git a/lib/features/settings/view/manage_accounts_page.dart b/lib/features/settings/view/manage_accounts_page.dart new file mode 100644 index 0000000..9517671 --- /dev/null +++ b/lib/features/settings/view/manage_accounts_page.dart @@ -0,0 +1,136 @@ +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/cubit/authentication_cubit.dart'; +import 'package:paperless_mobile/features/login/model/login_form_credentials.dart'; +import 'package:paperless_mobile/features/login/model/user_account.dart'; +import 'package:paperless_mobile/features/login/view/login_page.dart'; +import 'package:paperless_mobile/features/settings/model/global_settings.dart'; +import 'package:paperless_mobile/features/settings/view/dialogs/switch_account_dialog.dart'; +import 'package:paperless_mobile/features/settings/view/pages/switching_accounts_page.dart'; +import 'package:paperless_mobile/features/settings/view/widgets/global_settings_builder.dart'; + +class ManageAccountsPage extends StatelessWidget { + const ManageAccountsPage({super.key}); + + @override + Widget build(BuildContext context) { + return Dialog.fullscreen( + child: Scaffold( + appBar: AppBar( + leading: CloseButton(), + title: Text("Manage Accounts"), //TODO: INTL + ), + floatingActionButton: FloatingActionButton.extended( + onPressed: () { + Navigator.push( + context, + MaterialPageRoute( + builder: (context) => LoginPage( + onSubmit: (context, username, password, serverUrl, clientCertificate) async { + final userId = await context.read().addAccount( + credentials: LoginFormCredentials( + username: username, + password: password, + ), + clientCertificate: clientCertificate, + serverUrl: serverUrl, + //TODO: Ask user whether to enable biometric authentication + enableBiometricAuthentication: false, + ); + final shoudSwitch = await showDialog( + context: context, + builder: (context) => + SwitchAccountDialog(username: username, serverUrl: serverUrl), + ) ?? + false; + if (shoudSwitch) { + context.read().switchAccount(userId); + } + }, + submitText: "Add account", + ), + ), + ); + }, + label: Text("Add account"), + icon: Icon(Icons.person_add), + ), + body: GlobalSettingsBuilder( + builder: (context, globalSettings) { + return ValueListenableBuilder( + valueListenable: Hive.box(HiveBoxes.userAccount).listenable(), + builder: (context, box, _) { + final userIds = box.keys.toList().cast(); + return ListView.builder( + itemBuilder: (context, index) { + return _buildAccountTile( + context, + userIds[index], + box.get(userIds[index])!, + globalSettings, + ); + }, + itemCount: userIds.length, + ); + }, + ); + }, + ), + ), + ); + } + + Widget _buildAccountTile( + BuildContext context, String userId, UserAccount account, GlobalSettings settings) { + final theme = Theme.of(context); + return ListTile( + selected: userId == settings.currentLoggedInUser, + title: Text(account.username), + subtitle: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + if (account.fullName != null) Text(account.fullName!), + Text(account.serverUrl), + ], + ), + isThreeLine: true, + leading: CircleAvatar( + child: Text((account.fullName ?? account.username) + .split(" ") + .take(2) + .map((e) => e.substring(0, 1)) + .map((e) => e.toUpperCase()) + .join(" ")), + ), + onTap: () async { + final navigator = Navigator.of(context); + if (settings.currentLoggedInUser == userId) return; + Navigator.pushReplacement( + context, + MaterialPageRoute( + builder: (context) => SwitchingAccountsPage(), + ), + ); + await context.read().switchAccount(userId); + navigator.popUntil((route) => route.isFirst); + }, + trailing: TextButton( + child: Text( + "Remove", + style: TextStyle( + color: theme.colorScheme.error, + ), + ), + onPressed: () async { + final shouldPop = userId == settings.currentLoggedInUser; + await context.read().removeAccount(userId); + if (shouldPop) { + Navigator.pop(context); + } + }, + ), + ); + } +} diff --git a/lib/features/settings/view/pages/switching_accounts_page.dart b/lib/features/settings/view/pages/switching_accounts_page.dart new file mode 100644 index 0000000..fd73578 --- /dev/null +++ b/lib/features/settings/view/pages/switching_accounts_page.dart @@ -0,0 +1,21 @@ +import 'package:flutter/material.dart'; +import 'package:flutter/src/widgets/framework.dart'; +import 'package:flutter/src/widgets/placeholder.dart'; + +class SwitchingAccountsPage extends StatelessWidget { + const SwitchingAccountsPage({super.key}); + + @override + Widget build(BuildContext context) { + return WillPopScope( + onWillPop: () async { + return false; + }, + child: Material( + child: Center( + child: Text("Switching accounts. Please wait..."), + ), + ), + ); + } +} diff --git a/lib/features/settings/view/settings_page.dart b/lib/features/settings/view/settings_page.dart index ba53bc8..d3c8cba 100644 --- a/lib/features/settings/view/settings_page.dart +++ b/lib/features/settings/view/settings_page.dart @@ -2,12 +2,9 @@ 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}); @@ -18,14 +15,13 @@ class SettingsPage extends StatelessWidget { appBar: AppBar( title: Text(S.of(context)!.settings), ), - bottomNavigationBar: BlocBuilder( + bottomNavigationBar: + BlocBuilder( builder: (context, state) { final info = state.information!; return ListTile( title: Text( - S.of(context)!.loggedInAs(info.username ?? 'unknown') + - "@${info.host}", + S.of(context)!.loggedInAs(info.username ?? 'unknown') + "@${info.host}", style: Theme.of(context).textTheme.labelSmall, textAlign: TextAlign.center, ), diff --git a/lib/features/settings/view/widgets/biometric_authentication_setting.dart b/lib/features/settings/view/widgets/biometric_authentication_setting.dart index ca02e14..1c36e06 100644 --- a/lib/features/settings/view/widgets/biometric_authentication_setting.dart +++ b/lib/features/settings/view/widgets/biometric_authentication_setting.dart @@ -3,9 +3,8 @@ 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/model/global_settings.dart'; +import 'package:paperless_mobile/features/settings/model/user_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'; 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 3686668..818ccab 100644 --- a/lib/features/settings/view/widgets/color_scheme_option_setting.dart +++ b/lib/features/settings/view/widgets/color_scheme_option_setting.dart @@ -7,8 +7,7 @@ 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/global_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'; @@ -37,8 +36,7 @@ class ColorSchemeOptionSetting extends StatelessWidget { options: [ RadioOption( value: ColorSchemeOption.classic, - label: translateColorSchemeOption( - context, ColorSchemeOption.classic), + label: translateColorSchemeOption(context, ColorSchemeOption.classic), ), RadioOption( value: ColorSchemeOption.dynamic, @@ -71,8 +69,7 @@ class ColorSchemeOptionSetting extends StatelessWidget { bool _isBelowAndroid12() { if (Platform.isAndroid) { - final int version = - int.tryParse(androidInfo!.version.release ?? '0') ?? 0; + final int version = int.tryParse(androidInfo!.version.release ?? '0') ?? 0; return version < 12; } return false; diff --git a/lib/features/settings/view/widgets/global_settings_builder.dart b/lib/features/settings/view/widgets/global_settings_builder.dart index 9727943..b20a5b1 100644 --- a/lib/features/settings/view/widgets/global_settings_builder.dart +++ b/lib/features/settings/view/widgets/global_settings_builder.dart @@ -3,21 +3,18 @@ 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'; +import 'package:paperless_mobile/features/settings/model/global_settings.dart'; class GlobalSettingsBuilder extends StatelessWidget { - - final Widget Function(BuildContext context, GlobalAppSettings settings) - builder; + final Widget Function(BuildContext context, GlobalSettings settings) builder; const GlobalSettingsBuilder({super.key, required this.builder}); @override Widget build(BuildContext context) { return ValueListenableBuilder( - valueListenable: - Hive.box(HiveBoxes.globalSettings).listenable(), + valueListenable: Hive.box(HiveBoxes.globalSettings).listenable(), builder: (context, value, _) { - final settings = value.get(HiveBoxSingleValueKey.value)!; + final settings = value.getValue()!; 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 23a9a35..30a1ad7 100644 --- a/lib/features/settings/view/widgets/language_selection_setting.dart +++ b/lib/features/settings/view/widgets/language_selection_setting.dart @@ -1,8 +1,7 @@ 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/model/global_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'; @@ -11,8 +10,7 @@ class LanguageSelectionSetting extends StatefulWidget { const LanguageSelectionSetting({super.key}); @override - State createState() => - _LanguageSelectionSettingState(); + State createState() => _LanguageSelectionSettingState(); } class _LanguageSelectionSettingState extends State { diff --git a/lib/features/settings/view/widgets/theme_mode_setting.dart b/lib/features/settings/view/widgets/theme_mode_setting.dart index 4fff3ec..a0e490d 100644 --- a/lib/features/settings/view/widgets/theme_mode_setting.dart +++ b/lib/features/settings/view/widgets/theme_mode_setting.dart @@ -1,6 +1,5 @@ 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'; @@ -14,8 +13,7 @@ class ThemeModeSetting extends StatelessWidget { builder: (context, settings) { return ListTile( title: Text(S.of(context)!.appearance), - subtitle: Text(_mapThemeModeToLocalizedString( - settings.preferredThemeMode, context)), + subtitle: Text(_mapThemeModeToLocalizedString(settings.preferredThemeMode, context)), onTap: () => showDialog( context: context, builder: (_) => RadioSettingsDialog( diff --git a/lib/features/settings/view/widgets/user_settings_builder.dart b/lib/features/settings/view/widgets/user_settings_builder.dart index 5497390..b1be602 100644 --- a/lib/features/settings/view/widgets/user_settings_builder.dart +++ b/lib/features/settings/view/widgets/user_settings_builder.dart @@ -1,13 +1,13 @@ 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'; +import 'package:paperless_mobile/features/settings/model/global_settings.dart'; +import 'package:paperless_mobile/features/settings/model/user_settings.dart'; class UserSettingsBuilder extends StatelessWidget { final Widget Function( BuildContext context, - UserAppSettings? settings, + UserSettings? settings, ) builder; const UserSettingsBuilder({ @@ -17,14 +17,11 @@ class UserSettingsBuilder extends StatelessWidget { @override Widget build(BuildContext context) { - return ValueListenableBuilder>( - valueListenable: - Hive.box(HiveBoxes.userSettings).listenable(), + return ValueListenableBuilder>( + valueListenable: Hive.box(HiveBoxes.userSettings).listenable(), builder: (context, value, _) { final currentUser = - Hive.box(HiveBoxes.globalSettings) - .get(HiveBoxSingleValueKey.value) - ?.currentLoggedInUser; + Hive.box(HiveBoxes.globalSettings).getValue()!.currentLoggedInUser; if (currentUser != null) { final settings = value.get(currentUser); return builder(context, settings); diff --git a/lib/l10n/intl_en.arb b/lib/l10n/intl_en.arb index 0f16bd8..2ccb0b5 100644 --- a/lib/l10n/intl_en.arb +++ b/lib/l10n/intl_en.arb @@ -49,7 +49,7 @@ }, "biometricAuthentication": "Biometric authentication", "@biometricAuthentication": {}, - "authenticateToToggleBiometricAuthentication": "{mode, select, enable{Authenticate enable biometric authentication} disable{Authenticate to disable biometric authentication} other{}}", + "authenticateToToggleBiometricAuthentication": "{mode, select, enable{Authenticate to enable biometric authentication} disable{Authenticate to disable biometric authentication} other{}}", "@authenticateToToggleBiometricAuthentication": { "placeholders": { "mode": {} diff --git a/lib/l10n/intl_pl.arb b/lib/l10n/intl_pl.arb index bbeff5e..f36f991 100644 --- a/lib/l10n/intl_pl.arb +++ b/lib/l10n/intl_pl.arb @@ -1,13 +1,13 @@ { - "developedBy": "Developed by {name}", + "developedBy": "Stworzone przez {name}", "@developedBy": { "placeholders": { "name": {} } }, - "addAnotherAccount": "Add another account", + "addAnotherAccount": "Dodaj kolejne konto", "@addAnotherAccount": {}, - "account": "Account", + "account": "Konto", "@account": {}, "addCorrespondent": "New Correspondent", "@addCorrespondent": { @@ -35,11 +35,11 @@ "name": {} } }, - "disconnect": "Disconnect", + "disconnect": "Rozłącz się", "@disconnect": { "description": "Logout button label" }, - "reportABug": "Report a Bug", + "reportABug": "Zgłoś błąd", "@reportABug": {}, "settings": "Ustawienia", "@settings": {}, @@ -49,7 +49,7 @@ }, "biometricAuthentication": "Biometric authentication", "@biometricAuthentication": {}, - "authenticateToToggleBiometricAuthentication": "{mode, select, enable{Authenticate enable biometric authentication} disable{Authenticate to disable biometric authentication} other{}}", + "authenticateToToggleBiometricAuthentication": "{mode, select, enable{Authenticate to enable biometric authentication} disable{Authenticate to disable biometric authentication} other{}}", "@authenticateToToggleBiometricAuthentication": { "placeholders": { "mode": {} @@ -67,7 +67,7 @@ "@startTyping": {}, "doYouReallyWantToDeleteThisView": "Do you really want to delete this view?", "@doYouReallyWantToDeleteThisView": {}, - "deleteView": "Delete view ", + "deleteView": "Usuń widok ", "@deleteView": {}, "addedAt": "Added at", "@addedAt": {}, @@ -141,13 +141,13 @@ }, "documentSuccessfullyDownloaded": "Document successfully downloaded.", "@documentSuccessfullyDownloaded": {}, - "suggestions": "Suggestions: ", + "suggestions": "Sugestie: ", "@suggestions": {}, "editDocument": "Edytuj Dokument", "@editDocument": {}, - "advanced": "Advanced", + "advanced": "Zaawansowane", "@advanced": {}, - "apply": "Apply", + "apply": "Zastosuj", "@apply": {}, "extended": "Extended", "@extended": {}, @@ -155,7 +155,7 @@ "@titleAndContent": {}, "title": "Tytuł", "@title": {}, - "reset": "Reset", + "reset": "Zresetuj", "@reset": {}, "filterDocuments": "Filter Documents", "@filterDocuments": { @@ -163,7 +163,7 @@ }, "originalMD5Checksum": "Original MD5-Checksum", "@originalMD5Checksum": {}, - "mediaFilename": "Media Filename", + "mediaFilename": "Nazwa pliku", "@mediaFilename": {}, "originalFileSize": "Original File Size", "@originalFileSize": {}, @@ -183,7 +183,7 @@ "@or": { "description": "Used on the scanner page between both main actions when no scans have been captured." }, - "deleteAllScans": "Delete all scans", + "deleteAllScans": "Usuń wszystkie skany", "@deleteAllScans": {}, "uploadADocumentFromThisDevice": "Upload a document from this device", "@uploadADocumentFromThisDevice": { @@ -201,7 +201,7 @@ }, "searchDocuments": "Search documents", "@searchDocuments": {}, - "resetFilter": "Reset filter", + "resetFilter": "Zresetuj filtr", "@resetFilter": {}, "lastMonth": "Last Month", "@lastMonth": {}, @@ -221,7 +221,7 @@ "@oops": {}, "newDocumentAvailable": "New document available!", "@newDocumentAvailable": {}, - "orderBy": "Order By", + "orderBy": "Sortuj według", "@orderBy": {}, "thisActionIsIrreversibleDoYouWishToProceedAnyway": "This action is irreversible. Do you wish to proceed anyway?", "@thisActionIsIrreversibleDoYouWishToProceedAnyway": {}, @@ -252,7 +252,7 @@ "@fileName": {}, "synchronizeTitleAndFilename": "Synchronize title and filename", "@synchronizeTitleAndFilename": {}, - "reload": "Reload", + "reload": "Odśwież", "@reload": {}, "documentSuccessfullyUploadedProcessing": "Dokument pomyślnie przesłany, przetwarzam...", "@documentSuccessfullyUploadedProcessing": {}, diff --git a/lib/l10n/intl_ru.arb b/lib/l10n/intl_ru.arb index 1c320de..0cbc425 100644 --- a/lib/l10n/intl_ru.arb +++ b/lib/l10n/intl_ru.arb @@ -49,7 +49,7 @@ }, "biometricAuthentication": "Biometric authentication", "@biometricAuthentication": {}, - "authenticateToToggleBiometricAuthentication": "{mode, select, enable{Authenticate enable biometric authentication} disable{Authenticate to disable biometric authentication} other{}}", + "authenticateToToggleBiometricAuthentication": "{mode, select, enable{Authenticate to enable biometric authentication} disable{Authenticate to disable biometric authentication} other{}}", "@authenticateToToggleBiometricAuthentication": { "placeholders": { "mode": {} diff --git a/lib/main.dart b/lib/main.dart index 17201a7..cdc4385 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -2,6 +2,7 @@ import 'dart:io'; import 'package:connectivity_plus/connectivity_plus.dart'; import 'package:device_info_plus/device_info_plus.dart'; +import 'package:dynamic_color/dynamic_color.dart'; import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:flutter_cache_manager/flutter_cache_manager.dart' as cm; @@ -9,14 +10,13 @@ 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'; import 'package:intl/intl_standalone.dart'; import 'package:local_auth/local_auth.dart'; import 'package:package_info_plus/package_info_plus.dart'; import 'package:paperless_api/paperless_api.dart'; -import 'package:paperless_mobile/core/bloc/bloc_changes_observer.dart'; +import 'package:paperless_mobile/constants.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'; @@ -28,32 +28,33 @@ import 'package:paperless_mobile/core/repository/saved_view_repository.dart'; import 'package:paperless_mobile/core/security/session_manager.dart'; import 'package:paperless_mobile/core/service/connectivity_status_service.dart'; import 'package:paperless_mobile/core/service/dio_file_service.dart'; +import 'package:paperless_mobile/core/type/types.dart'; import 'package:paperless_mobile/features/app_intro/application_intro_slideshow.dart'; import 'package:paperless_mobile/features/home/view/home_page.dart'; import 'package:paperless_mobile/features/home/view/widget/verify_identity_page.dart'; import 'package:paperless_mobile/features/login/cubit/authentication_cubit.dart'; +import 'package:paperless_mobile/features/login/model/client_certificate.dart'; +import 'package:paperless_mobile/features/login/model/login_form_credentials.dart'; +import 'package:paperless_mobile/features/login/model/user_account.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/model/global_settings.dart'; +import 'package:paperless_mobile/features/settings/model/user_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'; +import 'package:paperless_mobile/helpers/message_helpers.dart'; import 'package:paperless_mobile/routes/document_details_route.dart'; import 'package:paperless_mobile/theme.dart'; -import 'package:paperless_mobile/constants.dart'; import 'package:path_provider/path_provider.dart'; 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)) { + if (!S.supportedLocales.any((locale) => locale.languageCode == preferredLocale)) { preferredLocale = 'en'; } return preferredLocale; @@ -61,23 +62,25 @@ String get defaultPreferredLocaleSubtag { Future _initHive() async { await Hive.initFlutter(); + //TODO: REMOVE! + // await getApplicationDocumentsDirectory().then((value) => value.delete(recursive: true)); + registerHiveAdapters(); - final globalSettingsBox = - await Hive.openBox(HiveBoxes.globalSettings); - if (!globalSettingsBox.containsKey(HiveBoxSingleValueKey.value)) { - await globalSettingsBox.put( - HiveBoxSingleValueKey.value, - GlobalAppSettings(preferredLocaleSubtag: defaultPreferredLocaleSubtag), - ); + await Hive.openBox(HiveBoxes.userAccount); + await Hive.openBox(HiveBoxes.userSettings); + final globalSettingsBox = await Hive.openBox(HiveBoxes.globalSettings); + + if (!globalSettingsBox.hasValue) { + await globalSettingsBox + .setValue(GlobalSettings(preferredLocaleSubtag: defaultPreferredLocaleSubtag)); } } void main() async { await _initHive(); final widgetsBinding = WidgetsFlutterBinding.ensureInitialized(); - - final globalSettings = Hive.box(HiveBoxes.globalSettings) - .get(HiveBoxSingleValueKey.value)!; + final globalSettingsBox = Hive.box(HiveBoxes.globalSettings); + final globalSettings = globalSettingsBox.getValue()!; await findSystemLocale(); packageInfo = await PackageInfo.fromPlatform(); @@ -95,9 +98,8 @@ void main() async { final connectivityStatusService = ConnectivityStatusServiceImpl(connectivity); final localAuthService = LocalAuthenticationService(localAuthentication); - final hiveDir = await getApplicationDocumentsDirectory(); HydratedBloc.storage = await HydratedStorage.build( - storageDirectory: hiveDir, + storageDirectory: await getApplicationDocumentsDirectory(), ); FlutterNativeSplash.preserve(widgetsBinding: widgetsBinding); @@ -136,34 +138,27 @@ void main() async { localAuthService, authApi, sessionManager, + labelRepository, + savedViewRepository, + statsApi, ); if (globalSettings.currentLoggedInUser != null) { - await authCubit - .restoreSessionState(); - } - - if (authCubit.state.isAuthenticated) { - final auth = authCubit.state.authentication!; - sessionManager.updateSettings( - baseUrl: auth.serverUrl, - authToken: auth.token, - clientCertificate: auth.clientCertificate, - ); + await authCubit.restoreSessionState(); } final localNotificationService = LocalNotificationService(); await localNotificationService.initialize(); //Update language header in interceptor on language change. - globalSettings.addListener( - () => languageHeaderInterceptor.preferredLocaleSubtag = - globalSettings.preferredLocaleSubtag, - ); + globalSettingsBox.listenable().addListener(() { + languageHeaderInterceptor.preferredLocaleSubtag = globalSettings.preferredLocaleSubtag; + }); runApp( MultiProvider( providers: [ + Provider.value(value: localAuthService), Provider.value(value: authApi), Provider.value(value: documentsApi), Provider.value(value: labelsApi), @@ -181,8 +176,7 @@ void main() async { Provider.value( value: connectivityStatusService, ), - Provider.value( - value: localNotificationService), + Provider.value(value: localNotificationService), Provider.value(value: DocumentChangedNotifier()), ], child: MultiRepositoryProvider( @@ -212,59 +206,48 @@ class PaperlessMobileEntrypoint extends StatefulWidget { }) : super(key: key); @override - State createState() => - _PaperlessMobileEntrypointState(); + State createState() => _PaperlessMobileEntrypointState(); } class _PaperlessMobileEntrypointState extends State { @override Widget build(BuildContext context) { - return MultiBlocProvider( - providers: [ - BlocProvider( - create: (context) => PaperlessServerInformationCubit( - context.read(), - ), - ), - ], - child: GlobalSettingsBuilder( - builder: (context, settings) { - return DynamicColorBuilder( - builder: (lightDynamic, darkDynamic) { - return MaterialApp( - debugShowCheckedModeBanner: true, - title: "Paperless Mobile", - theme: buildTheme( - brightness: Brightness.light, - dynamicScheme: lightDynamic, - preferredColorScheme: settings.preferredColorSchemeOption, - ), - darkTheme: buildTheme( - brightness: Brightness.dark, - dynamicScheme: darkDynamic, - preferredColorScheme: settings.preferredColorSchemeOption, - ), - themeMode: settings.preferredThemeMode, - supportedLocales: S.supportedLocales, - locale: Locale.fromSubtags( - languageCode: settings.preferredLocaleSubtag, - ), - localizationsDelegates: const [ - S.delegate, - GlobalMaterialLocalizations.delegate, - GlobalCupertinoLocalizations.delegate, - GlobalWidgetsLocalizations.delegate, - ], - routes: { - DocumentDetailsRoute.routeName: (context) => - const DocumentDetailsRoute(), - }, - home: const AuthenticationWrapper(), - ); - }, - ); - }, - ), + return GlobalSettingsBuilder( + builder: (context, settings) { + return DynamicColorBuilder( + builder: (lightDynamic, darkDynamic) { + return MaterialApp( + debugShowCheckedModeBanner: true, + title: "Paperless Mobile", + theme: buildTheme( + brightness: Brightness.light, + dynamicScheme: lightDynamic, + preferredColorScheme: settings.preferredColorSchemeOption, + ), + darkTheme: buildTheme( + brightness: Brightness.dark, + dynamicScheme: darkDynamic, + preferredColorScheme: settings.preferredColorSchemeOption, + ), + themeMode: settings.preferredThemeMode, + supportedLocales: S.supportedLocales, + locale: Locale.fromSubtags( + languageCode: settings.preferredLocaleSubtag, + ), + localizationsDelegates: const [ + S.delegate, + GlobalMaterialLocalizations.delegate, + GlobalCupertinoLocalizations.delegate, + GlobalWidgetsLocalizations.delegate, + ], + routes: { + DocumentDetailsRoute.routeName: (context) => const DocumentDetailsRoute(), + }, + home: const AuthenticationWrapper(), + ); + }, + ); + }, ); } } @@ -279,25 +262,23 @@ class AuthenticationWrapper extends StatefulWidget { class _AuthenticationWrapperState extends State { @override void didChangeDependencies() { - FlutterNativeSplash.remove(); super.didChangeDependencies(); + FlutterNativeSplash.remove(); } @override void initState() { super.initState(); - // Temporary Fix: Can be removed if the flutter engine implements the fix itself + // Activate the highest supported refresh rate on the device if (Platform.isAndroid) { _setOptimalDisplayMode(); } initializeDateFormatting(); // For sharing files coming from outside the app while the app is still opened - ReceiveSharingIntent.getMediaStream() - .listen(ShareIntentQueue.instance.addAll); + ReceiveSharingIntent.getMediaStream().listen(ShareIntentQueue.instance.addAll); // For sharing files coming from outside the app while the app is closed - ReceiveSharingIntent.getInitialMedia() - .then(ShareIntentQueue.instance.addAll); + ReceiveSharingIntent.getInitialMedia().then(ShareIntentQueue.instance.addAll); } Future _setOptimalDisplayMode() async { @@ -309,8 +290,7 @@ class _AuthenticationWrapperState extends State { .toList() ..sort((a, b) => b.refreshRate.compareTo(a.refreshRate)); - final DisplayMode mostOptimalMode = - sameResolution.isNotEmpty ? sameResolution.first : active; + final DisplayMode mostOptimalMode = sameResolution.isNotEmpty ? sameResolution.first : active; debugPrint('Setting refresh rate to ${mostOptimalMode.refreshRate}'); await FlutterDisplayMode.setPreferredMode(mostOptimalMode); @@ -318,36 +298,76 @@ class _AuthenticationWrapperState extends State { @override Widget build(BuildContext context) { - return BlocConsumer( - listener: (context, authState) { - final bool showIntroSlider = - authState.isAuthenticated && !authState.wasLoginStored; - if (showIntroSlider) { - Navigator.push( - context, - MaterialPageRoute( - builder: (context) => const ApplicationIntroSlideshow(), - fullscreenDialog: true, - ), - ); - } - }, + return BlocBuilder( builder: (context, authentication) { - if (authentication.isAuthenticated && - (authentication.wasLocalAuthenticationSuccessful ?? true)) { - return BlocProvider( - create: (context) => - TaskStatusCubit(context.read()), + if (authentication.isAuthenticated) { + return MultiBlocProvider( + // This key will cause the subtree to be remounted, which will again + // call the provider's create callback! Without this key, the blocs + // would not be recreated on account switch! + key: ValueKey(authentication.userId), + providers: [ + BlocProvider( + create: (context) => TaskStatusCubit( + context.read(), + ), + ), + BlocProvider( + create: (context) => PaperlessServerInformationCubit( + context.read(), + ), + ), + ], child: const HomePage(), ); - } else { - if (authentication.wasLoginStored && - !(authentication.wasLocalAuthenticationSuccessful ?? false)) { - return const VerifyIdentityPage(); - } - return const LoginPage(); + } else if (authentication.showBiometricAuthenticationScreen) { + return const VerifyIdentityPage(); } + return LoginPage( + submitText: S.of(context)!.signIn, + onSubmit: _onLogin, + ); }, ); } + + void _onLogin( + BuildContext context, + String username, + String password, + String serverUrl, + ClientCertificate? clientCertificate, + ) async { + try { + await context.read().login( + credentials: LoginFormCredentials(username: username, password: password), + serverUrl: serverUrl, + clientCertificate: clientCertificate, + ); + // Show onboarding after first login! + final globalSettings = GlobalSettings.boxedValue; + if (globalSettings.showOnboarding) { + Navigator.push( + context, + MaterialPageRoute( + builder: (context) => const ApplicationIntroSlideshow(), + fullscreenDialog: true, + ), + ).then((value) { + globalSettings.showOnboarding = false; + globalSettings.save(); + }); + } + } on PaperlessServerException catch (error, stackTrace) { + showErrorMessage(context, error, stackTrace); + } on PaperlessValidationErrors catch (error, stackTrace) { + if (error.hasFieldUnspecificError) { + showLocalizedError(context, error.fieldUnspecificError!); + } else { + showGenericError(context, error.values.first, stackTrace); + } + } catch (unknownError, stackTrace) { + showGenericError(context, unknownError.toString(), stackTrace); + } + } } diff --git a/packages/paperless_api/lib/src/models/paperless_ui_settings_model.dart b/packages/paperless_api/lib/src/models/paperless_ui_settings_model.dart new file mode 100644 index 0000000..4a41311 --- /dev/null +++ b/packages/paperless_api/lib/src/models/paperless_ui_settings_model.dart @@ -0,0 +1,14 @@ +import 'package:json_annotation/json_annotation.dart'; + +part 'paperless_ui_settings_model.g.dart'; + +@JsonSerializable(fieldRename: FieldRename.snake) +class PaperlessUiSettingsModel { + final String displayName; + + PaperlessUiSettingsModel({required this.displayName}); + factory PaperlessUiSettingsModel.fromJson(Map json) => + _$PaperlessUiSettingsModelFromJson(json); + + Map toJson() => _$PaperlessUiSettingsModelToJson(this); +} diff --git a/packages/paperless_api/lib/src/modules/server_stats_api/paperless_server_stats_api.dart b/packages/paperless_api/lib/src/modules/server_stats_api/paperless_server_stats_api.dart index 6dba8f1..a943adf 100644 --- a/packages/paperless_api/lib/src/modules/server_stats_api/paperless_server_stats_api.dart +++ b/packages/paperless_api/lib/src/modules/server_stats_api/paperless_server_stats_api.dart @@ -1,7 +1,9 @@ import 'package:paperless_api/src/models/paperless_server_information_model.dart'; import 'package:paperless_api/src/models/paperless_server_statistics_model.dart'; +import 'package:paperless_api/src/models/paperless_ui_settings_model.dart'; abstract class PaperlessServerStatsApi { Future getServerInformation(); Future getServerStatistics(); + Future getUiSettings(); } diff --git a/packages/paperless_api/lib/src/modules/server_stats_api/paperless_server_stats_api_impl.dart b/packages/paperless_api/lib/src/modules/server_stats_api/paperless_server_stats_api_impl.dart index 0d44ad6..d0e21fa 100644 --- a/packages/paperless_api/lib/src/modules/server_stats_api/paperless_server_stats_api_impl.dart +++ b/packages/paperless_api/lib/src/modules/server_stats_api/paperless_server_stats_api_impl.dart @@ -5,6 +5,7 @@ import 'package:http/http.dart'; import 'package:paperless_api/src/models/paperless_server_exception.dart'; import 'package:paperless_api/src/models/paperless_server_information_model.dart'; import 'package:paperless_api/src/models/paperless_server_statistics_model.dart'; +import 'package:paperless_api/src/models/paperless_ui_settings_model.dart'; import 'paperless_server_stats_api.dart'; @@ -21,15 +22,12 @@ class PaperlessServerStatsApiImpl implements PaperlessServerStatsApi { @override Future getServerInformation() async { final response = await client.get("/api/ui_settings/"); - final version = response - .headers[PaperlessServerInformationModel.versionHeader]?.first ?? - 'unknown'; - final apiVersion = int.tryParse(response - .headers[PaperlessServerInformationModel.apiVersionHeader]?.first ?? - '1'); + final version = + response.headers[PaperlessServerInformationModel.versionHeader]?.first ?? 'unknown'; + final apiVersion = int.tryParse( + response.headers[PaperlessServerInformationModel.apiVersionHeader]?.first ?? '1'); final String username = response.data['username']; - final String host = response - .headers[PaperlessServerInformationModel.hostHeader]?.first ?? + final String host = response.headers[PaperlessServerInformationModel.hostHeader]?.first ?? response.headers[PaperlessServerInformationModel.hostHeader]?.first ?? ('${response.requestOptions.uri.host}:${response.requestOptions.uri.port}'); return PaperlessServerInformationModel( @@ -48,4 +46,13 @@ class PaperlessServerStatsApiImpl implements PaperlessServerStatsApi { } throw const PaperlessServerException.unknown(); } + + @override + Future getUiSettings() async { + final response = await client.get("/api/ui_settings/"); + if (response.statusCode == 200) { + return PaperlessUiSettingsModel.fromJson(response.data); + } + throw const PaperlessServerException.unknown(); + } }