diff --git a/lib/core/bloc/bloc_refresh_listenable.dart b/lib/core/bloc/bloc_refresh_listenable.dart deleted file mode 100644 index f7b8067..0000000 --- a/lib/core/bloc/bloc_refresh_listenable.dart +++ /dev/null @@ -1,20 +0,0 @@ -import 'dart:async'; - -import 'package:flutter/foundation.dart'; - -class GoRouterRefreshStream extends ChangeNotifier { - GoRouterRefreshStream(Stream stream) { - notifyListeners(); - _subscription = stream.asBroadcastStream().listen( - (dynamic _) => notifyListeners(), - ); - } - - late final StreamSubscription _subscription; - - @override - void dispose() { - _subscription.cancel(); - super.dispose(); - } -} diff --git a/lib/core/bloc/document_status_cubit.dart b/lib/core/bloc/document_status_cubit.dart deleted file mode 100644 index 84121dd..0000000 --- a/lib/core/bloc/document_status_cubit.dart +++ /dev/null @@ -1,8 +0,0 @@ -import 'package:flutter_bloc/flutter_bloc.dart'; -import 'package:paperless_mobile/core/model/document_processing_status.dart'; - -class DocumentStatusCubit extends Cubit { - DocumentStatusCubit() : super(null); - - void updateStatus(DocumentProcessingStatus? status) => emit(status); -} diff --git a/lib/core/navigation/push_routes.dart b/lib/core/navigation/push_routes.dart deleted file mode 100644 index 0dfad7c..0000000 --- a/lib/core/navigation/push_routes.dart +++ /dev/null @@ -1,231 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:flutter_bloc/flutter_bloc.dart'; -import 'package:flutter_cache_manager/flutter_cache_manager.dart'; -import 'package:paperless_api/paperless_api.dart'; -import 'package:paperless_mobile/core/bloc/connectivity_cubit.dart'; -import 'package:paperless_mobile/core/database/tables/local_user_account.dart'; -import 'package:paperless_mobile/core/database/tables/local_user_app_state.dart'; -import 'package:paperless_mobile/core/notifier/document_changed_notifier.dart'; -import 'package:paperless_mobile/core/repository/label_repository.dart'; -import 'package:paperless_mobile/core/repository/user_repository.dart'; -import 'package:paperless_mobile/features/document_bulk_action/cubit/document_bulk_action_cubit.dart'; -import 'package:paperless_mobile/features/document_bulk_action/view/widgets/fullscreen_bulk_edit_label_page.dart'; -import 'package:paperless_mobile/features/document_bulk_action/view/widgets/fullscreen_bulk_edit_tags_widget.dart'; -import 'package:paperless_mobile/features/home/view/model/api_version.dart'; -import 'package:paperless_mobile/features/saved_view/cubit/saved_view_cubit.dart'; -import 'package:paperless_mobile/features/saved_view_details/cubit/saved_view_details_cubit.dart'; -import 'package:paperless_mobile/features/saved_view_details/view/saved_view_details_page.dart'; -import 'package:paperless_mobile/generated/l10n/app_localizations.dart'; -import 'package:provider/provider.dart'; - -Future pushSavedViewDetailsRoute( - BuildContext context, { - required SavedView savedView, -}) { - final apiVersion = context.read(); - return Navigator.of(context).push( - MaterialPageRoute( - builder: (_) => MultiProvider( - providers: [ - Provider.value(value: apiVersion), - if (context.watch().hasMultiUserSupport) - Provider.value(value: context.read()), - Provider.value(value: context.read()), - Provider.value(value: context.read()), - Provider.value(value: context.read()), - Provider.value(value: context.read()), - Provider.value(value: context.read()), - ], - builder: (_, child) { - return BlocProvider( - create: (context) => SavedViewDetailsCubit( - context.read(), - context.read(), - context.read(), - LocalUserAppState.current, - context.read(), - savedView: savedView, - ), - child: SavedViewDetailsPage( - onDelete: context.read().remove, - ), - ); - }, - ), - ), - ); -} - -Future pushBulkEditCorrespondentRoute( - BuildContext context, { - required List selection, -}) { - return Navigator.of(context).push( - MaterialPageRoute( - builder: (_) => MultiProvider( - providers: [ - ..._getRequiredBulkEditProviders(context), - ], - builder: (_, __) => BlocProvider( - create: (_) => DocumentBulkActionCubit( - context.read(), - context.read(), - context.read(), - selection: selection, - ), - child: BlocBuilder( - builder: (context, state) { - return FullscreenBulkEditLabelPage( - options: state.correspondents, - selection: state.selection, - labelMapper: (document) => document.correspondent, - leadingIcon: const Icon(Icons.person_outline), - hintText: S.of(context)!.startTyping, - onSubmit: context - .read() - .bulkModifyCorrespondent, - assignMessageBuilder: (int count, String name) { - return S.of(context)!.bulkEditCorrespondentAssignMessage( - name, - count, - ); - }, - removeMessageBuilder: (int count) { - return S - .of(context)! - .bulkEditCorrespondentRemoveMessage(count); - }, - ); - }, - ), - ), - ), - ), - ); -} - -Future pushBulkEditStoragePathRoute( - BuildContext context, { - required List selection, -}) { - return Navigator.of(context).push( - MaterialPageRoute( - builder: (_) => MultiProvider( - providers: [ - ..._getRequiredBulkEditProviders(context), - ], - builder: (_, __) => BlocProvider( - create: (_) => DocumentBulkActionCubit( - context.read(), - context.read(), - context.read(), - selection: selection, - ), - child: BlocBuilder( - builder: (context, state) { - return FullscreenBulkEditLabelPage( - options: state.storagePaths, - selection: state.selection, - labelMapper: (document) => document.storagePath, - leadingIcon: const Icon(Icons.folder_outlined), - hintText: S.of(context)!.startTyping, - onSubmit: context - .read() - .bulkModifyStoragePath, - assignMessageBuilder: (int count, String name) { - return S.of(context)!.bulkEditStoragePathAssignMessage( - count, - name, - ); - }, - removeMessageBuilder: (int count) { - return S.of(context)!.bulkEditStoragePathRemoveMessage(count); - }, - ); - }, - ), - ), - ), - ), - ); -} - -Future pushBulkEditTagsRoute( - BuildContext context, { - required List selection, -}) { - return Navigator.of(context).push( - MaterialPageRoute( - builder: (_) => MultiProvider( - providers: [ - ..._getRequiredBulkEditProviders(context), - ], - builder: (_, __) => BlocProvider( - create: (_) => DocumentBulkActionCubit( - context.read(), - context.read(), - context.read(), - selection: selection, - ), - child: Builder(builder: (context) { - return const FullscreenBulkEditTagsWidget(); - }), - ), - ), - ), - ); -} - -Future pushBulkEditDocumentTypeRoute(BuildContext context, - {required List selection}) { - return Navigator.of(context).push( - MaterialPageRoute( - builder: (_) => MultiProvider( - providers: [ - ..._getRequiredBulkEditProviders(context), - ], - builder: (_, __) => BlocProvider( - create: (_) => DocumentBulkActionCubit( - context.read(), - context.read(), - context.read(), - selection: selection, - ), - child: BlocBuilder( - builder: (context, state) { - return FullscreenBulkEditLabelPage( - options: state.documentTypes, - selection: state.selection, - labelMapper: (document) => document.documentType, - leadingIcon: const Icon(Icons.description_outlined), - hintText: S.of(context)!.startTyping, - onSubmit: context - .read() - .bulkModifyDocumentType, - assignMessageBuilder: (int count, String name) { - return S.of(context)!.bulkEditDocumentTypeAssignMessage( - count, - name, - ); - }, - removeMessageBuilder: (int count) { - return S - .of(context)! - .bulkEditDocumentTypeRemoveMessage(count); - }, - ); - }, - ), - ), - ), - ), - ); -} - -List _getRequiredBulkEditProviders(BuildContext context) { - return [ - Provider.value(value: context.read()), - Provider.value(value: context.read()), - Provider.value(value: context.read()), - ]; -} diff --git a/lib/core/notifier/document_changed_notifier.dart b/lib/core/notifier/document_changed_notifier.dart index 04a9781..e1c2bba 100644 --- a/lib/core/notifier/document_changed_notifier.dart +++ b/lib/core/notifier/document_changed_notifier.dart @@ -12,6 +12,10 @@ class DocumentChangedNotifier { final Map> _subscribers = {}; + Stream get $updated => _updated.asBroadcastStream(); + + Stream get $deleted => _deleted.asBroadcastStream(); + void notifyUpdated(DocumentModel updated) { debugPrint("Notifying updated document ${updated.id}"); _updated.add(updated); diff --git a/lib/core/service/file_description.dart b/lib/core/service/file_description.dart deleted file mode 100644 index 5b0a81c..0000000 --- a/lib/core/service/file_description.dart +++ /dev/null @@ -1,20 +0,0 @@ -class FileDescription { - final String filename; - final String extension; - - FileDescription({ - required this.filename, - required this.extension, - }); - - factory FileDescription.fromPath(String path) { - final filename = path.split(RegExp(r"/")).last; - final fragments = filename.split("."); - final ext = fragments.removeLast(); - final name = fragments.join("."); - return FileDescription( - filename: name, - extension: ext, - ); - } -} diff --git a/lib/core/service/file_service.dart b/lib/core/service/file_service.dart index 61d7832..32482a2 100644 --- a/lib/core/service/file_service.dart +++ b/lib/core/service/file_service.dart @@ -1,7 +1,6 @@ import 'dart:io'; import 'package:flutter/foundation.dart'; -import 'package:paperless_api/paperless_api.dart'; import 'package:path_provider/path_provider.dart'; import 'package:rxdart/rxdart.dart'; import 'package:uuid/uuid.dart'; @@ -14,9 +13,6 @@ class FileService { String filename, ) async { final dir = await documentsDirectory; - if (dir == null) { - throw const PaperlessApiException.unknown(); //TODO: better handling - } File file = File("${dir.path}/$filename"); return file..writeAsBytes(bytes); } @@ -43,7 +39,7 @@ class FileService { static Future get temporaryDirectory => getTemporaryDirectory(); - static Future get documentsDirectory async { + static Future get documentsDirectory async { if (Platform.isAndroid) { return (await getExternalStorageDirectories( type: StorageDirectory.documents, diff --git a/lib/core/service/status_service.dart b/lib/core/service/status_service.dart deleted file mode 100644 index ea16bee..0000000 --- a/lib/core/service/status_service.dart +++ /dev/null @@ -1,103 +0,0 @@ -import 'dart:io'; - -import 'package:dio/dio.dart'; -import 'package:paperless_mobile/core/database/tables/user_credentials.dart'; -// import 'package:web_socket_channel/io.dart'; - -abstract class StatusService { - Future startListeningBeforeDocumentUpload( - String httpUrl, UserCredentials credentials, String documentFileName); -} - -class WebSocketStatusService implements StatusService { - late WebSocket? socket; - // late IOWebSocketChannel? _channel; - - WebSocketStatusService(); - - @override - Future startListeningBeforeDocumentUpload( - String httpUrl, - UserCredentials credentials, - String documentFileName, - ) async { - // socket = await WebSocket.connect( - // httpUrl.replaceFirst("http", "ws") + "/ws/status/", - // customClient: getIt(), - // headers: { - // 'Authorization': 'Token ${credentials.token}', - // }, - // ).catchError((_) { - // // Use long polling if connection could not be established - // }); - - // if (socket != null) { - // socket!.where(isNotNull).listen((event) { - // final status = DocumentProcessingStatus.fromJson(event); - // getIt().updateStatus(status); - // if (status.currentProgress == 100) { - // socket!.close(); - // } - // }); - // } - } -} - -class LongPollingStatusService implements StatusService { - final Dio client; - const LongPollingStatusService(this.client); - - @override - Future startListeningBeforeDocumentUpload( - String httpUrl, - UserCredentials credentials, - String documentFileName, - ) async { - // final today = DateTime.now(); - // bool consumptionFinished = false; - // int retryCount = 0; - - // getIt().updateStatus( - // DocumentProcessingStatus( - // currentProgress: 0, - // filename: documentFileName, - // maxProgress: 100, - // message: ProcessingMessage.new_file, - // status: ProcessingStatus.working, - // taskId: DocumentProcessingStatus.unknownTaskId, - // documentId: null, - // isApproximated: true, - // ), - // ); - - // do { - // final response = await httpClient.get( - // Uri.parse( - // '$httpUrl/api/documents/?query=$documentFileName added:${formatDate(today)}'), - // ); - // final data = await compute( - // PagedSearchResult.fromJson, - // PagedSearchResultJsonSerializer( - // jsonDecode(response.body), DocumentModel.fromJson), - // ); - // if (data.count > 0) { - // consumptionFinished = true; - // final docId = data.results[0].id; - // getIt().updateStatus( - // DocumentProcessingStatus( - // currentProgress: 100, - // filename: documentFileName, - // maxProgress: 100, - // message: ProcessingMessage.finished, - // status: ProcessingStatus.success, - // taskId: DocumentProcessingStatus.unknownTaskId, - // documentId: docId, - // isApproximated: true, - // ), - // ); - // return; - // } - // sleep(const Duration(seconds: 1)); - // } while (!consumptionFinished && retryCount < maxRetries); - } -} diff --git a/lib/core/widgets/app_options_popup_menu.dart b/lib/core/widgets/app_options_popup_menu.dart deleted file mode 100644 index 91b6bee..0000000 --- a/lib/core/widgets/app_options_popup_menu.dart +++ /dev/null @@ -1,218 +0,0 @@ -// import 'package:flutter/material.dart'; -// import 'package:flutter_bloc/flutter_bloc.dart'; -// import 'package:paperless_mobile/constants.dart'; -// import 'package:paperless_mobile/features/settings/bloc/application_settings_cubit.dart'; -// import 'package:paperless_mobile/features/settings/bloc/application_settings_state.dart'; -// import 'package:paperless_mobile/features/settings/model/view_type.dart'; -// import 'package:paperless_mobile/features/settings/view/settings_page.dart'; -// import 'package:paperless_mobile/generated/l10n/app_localizations.dart'; - -// import 'package:url_launcher/link.dart'; -// import 'package:url_launcher/url_launcher_string.dart'; - -// /// Declares selectable actions in menu. -// enum AppPopupMenuEntries { -// // Documents preview -// documentsSelectListView, -// documentsSelectGridView, -// // Generic actions -// openAboutThisAppDialog, -// reportBug, -// openSettings, -// // Adds a divider -// divider; -// } - -// class AppOptionsPopupMenu extends StatelessWidget { -// final List displayedActions; -// const AppOptionsPopupMenu({ -// super.key, -// required this.displayedActions, -// }); - -// @override -// Widget build(BuildContext context) { -// return PopupMenuButton( -// position: PopupMenuPosition.under, -// icon: const Icon(Icons.more_vert), -// onSelected: (action) { -// switch (action) { -// case AppPopupMenuEntries.documentsSelectListView: -// context.read().setViewType(ViewType.list); -// break; -// case AppPopupMenuEntries.documentsSelectGridView: -// context.read().setViewType(ViewType.grid); -// break; -// case AppPopupMenuEntries.openAboutThisAppDialog: -// _showAboutDialog(context); -// break; -// case AppPopupMenuEntries.openSettings: -// Navigator.of(context).push( -// MaterialPageRoute( -// builder: (context) => BlocProvider.value( -// value: context.read(), -// child: const SettingsPage(), -// ), -// ), -// ); -// break; -// case AppPopupMenuEntries.reportBug: -// launchUrlString( -// 'https://github.com/astubenbord/paperless-mobile/issues/new', -// ); -// break; -// default: -// break; -// } -// }, -// itemBuilder: _buildEntries, -// ); -// } - -// PopupMenuItem _buildReportBugTile(BuildContext context) { -// return PopupMenuItem( -// value: AppPopupMenuEntries.reportBug, -// padding: EdgeInsets.zero, -// child: ListTile( -// leading: const Icon(Icons.bug_report), -// title: Text(S.of(context)!.reportABug), -// ), -// ); -// } - -// PopupMenuItem _buildSettingsTile(BuildContext context) { -// return PopupMenuItem( -// padding: EdgeInsets.zero, -// value: AppPopupMenuEntries.openSettings, -// child: ListTile( -// leading: const Icon(Icons.settings_outlined), -// title: Text(S.of(context)!.settings), -// ), -// ); -// } - -// PopupMenuItem _buildAboutTile(BuildContext context) { -// return PopupMenuItem( -// padding: EdgeInsets.zero, -// value: AppPopupMenuEntries.openAboutThisAppDialog, -// child: ListTile( -// leading: const Icon(Icons.info_outline), -// title: Text(S.of(context)!.aboutThisApp), -// ), -// ); -// } - -// PopupMenuItem _buildListViewTile() { -// return PopupMenuItem( -// padding: EdgeInsets.zero, -// child: BlocBuilder( -// builder: (context, state) { -// return ListTile( -// leading: const Icon(Icons.list), -// title: const Text("List"), -// trailing: state.preferredViewType == ViewType.list -// ? const Icon(Icons.check) -// : null, -// ); -// }, -// ), -// value: AppPopupMenuEntries.documentsSelectListView, -// ); -// } - -// PopupMenuItem _buildGridViewTile() { -// return PopupMenuItem( -// value: AppPopupMenuEntries.documentsSelectGridView, -// padding: EdgeInsets.zero, -// child: BlocBuilder( -// builder: (context, state) { -// return ListTile( -// leading: const Icon(Icons.grid_view_rounded), -// title: const Text("Grid"), -// trailing: state.preferredViewType == ViewType.grid -// ? const Icon(Icons.check) -// : null, -// ); -// }, -// ), -// ); -// } - -// void _showAboutDialog(BuildContext context) { -// showAboutDialog( -// context: context, -// applicationIcon: const ImageIcon( -// AssetImage('assets/logos/paperless_logo_green.png'), -// ), -// applicationName: 'Paperless Mobile', -// applicationVersion: packageInfo.version + '+' + packageInfo.buildNumber, -// children: [ -// Text(S.of(context)!.developedBy('Anton Stubenbord')), -// Link( -// uri: Uri.parse('https://github.com/astubenbord/paperless-mobile'), -// builder: (context, followLink) => GestureDetector( -// onTap: followLink, -// child: Text( -// 'https://github.com/astubenbord/paperless-mobile', -// style: TextStyle(color: Theme.of(context).colorScheme.tertiary), -// ), -// ), -// ), -// const SizedBox(height: 16), -// Text( -// 'Credits', -// style: Theme.of(context).textTheme.titleMedium, -// ), -// _buildOnboardingImageCredits(), -// ], -// ); -// } - -// Widget _buildOnboardingImageCredits() { -// return Link( -// uri: Uri.parse( -// 'https://www.freepik.com/free-vector/business-team-working-cogwheel-mechanism-together_8270974.htm#query=setting&position=4&from_view=author'), -// builder: (context, followLink) => Wrap( -// children: [ -// const Text('Onboarding images by '), -// GestureDetector( -// onTap: followLink, -// child: Text( -// 'pch.vector', -// style: TextStyle(color: Theme.of(context).colorScheme.tertiary), -// ), -// ), -// const Text(' on Freepik.') -// ], -// ), -// ); -// } - -// List> _buildEntries( -// BuildContext context) { -// List> items = []; -// for (final entry in displayedActions) { -// switch (entry) { -// case AppPopupMenuEntries.documentsSelectListView: -// items.add(_buildListViewTile()); -// break; -// case AppPopupMenuEntries.documentsSelectGridView: -// items.add(_buildGridViewTile()); -// break; -// case AppPopupMenuEntries.openAboutThisAppDialog: -// items.add(_buildAboutTile(context)); -// break; -// case AppPopupMenuEntries.reportBug: -// items.add(_buildReportBugTile(context)); -// break; -// case AppPopupMenuEntries.openSettings: -// items.add(_buildSettingsTile(context)); -// break; -// case AppPopupMenuEntries.divider: -// items.add(const PopupMenuDivider()); -// break; -// } -// } -// return items; -// } -// } diff --git a/lib/core/widgets/form_builder_fields/form_builder_type_ahead.dart b/lib/core/widgets/form_builder_fields/form_builder_type_ahead.dart deleted file mode 100644 index bbd8480..0000000 --- a/lib/core/widgets/form_builder_fields/form_builder_type_ahead.dart +++ /dev/null @@ -1,430 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:flutter_form_builder/flutter_form_builder.dart'; -import 'package:flutter_typeahead/flutter_typeahead.dart'; - -typedef SelectionToTextTransformer = String Function(T suggestion); - -/// Text field that auto-completes user input from a list of items -class FormBuilderTypeAhead extends FormBuilderField { - /// Called with the search pattern to get the search suggestions. - /// - /// This callback must not be null. It is be called by the TypeAhead widget - /// and provided with the search pattern. It should return a [List](https://api.dartlang.org/stable/2.0.0/dart-core/List-class.html) - /// of suggestions either synchronously, or asynchronously (as the result of a - /// [Future](https://api.dartlang.org/stable/dart-async/Future-class.html)). - /// Typically, the list of suggestions should not contain more than 4 or 5 - /// entries. These entries will then be provided to [itemBuilder] to display - /// the suggestions. - /// - /// Example: - /// ```dart - /// suggestionsCallback: (pattern) async { - /// return await _getSuggestions(pattern); - /// } - /// ``` - final SuggestionsCallback suggestionsCallback; - - /// Called when a suggestion is tapped. - /// - /// This callback must not be null. It is called by the TypeAhead widget and - /// provided with the value of the tapped suggestion. - /// - /// For example, you might want to navigate to a specific view when the user - /// tabs a suggestion: - /// ```dart - /// onSuggestionSelected: (suggestion) { - /// Navigator.of(context).push(MaterialPageRoute( - /// builder: (context) => SearchResult( - /// searchItem: suggestion - /// ) - /// )); - /// } - /// ``` - /// - /// Or to set the value of the text field: - /// ```dart - /// onSuggestionSelected: (suggestion) { - /// _controller.text = suggestion['name']; - /// } - /// ``` - final SuggestionSelectionCallback? onSuggestionSelected; - - /// Called for each suggestion returned by [suggestionsCallback] to build the - /// corresponding widget. - /// - /// This callback must not be null. It is called by the TypeAhead widget for - /// each suggestion, and expected to build a widget to display this - /// suggestion's info. For example: - /// - /// ```dart - /// itemBuilder: (context, suggestion) { - /// return ListTile( - /// title: Text(suggestion['name']), - /// subtitle: Text('USD' + suggestion['price'].toString()) - /// ); - /// } - /// ``` - final ItemBuilder itemBuilder; - - /// The decoration of the material sheet that contains the suggestions. - /// - /// If null, default decoration with an elevation of 4.0 is used - final SuggestionsBoxDecoration suggestionsBoxDecoration; - - /// Used to control the `_SuggestionsBox`. Allows manual control to - /// open, close, toggle, or resize the `_SuggestionsBox`. - final SuggestionsBoxController? suggestionsBoxController; - - /// The duration to wait after the user stops typing before calling - /// [suggestionsCallback] - /// - /// This is useful, because, if not set, a request for suggestions will be - /// sent for every character that the user types. - /// - /// This duration is set by default to 300 milliseconds - final Duration debounceDuration; - - /// Called when waiting for [suggestionsCallback] to return. - /// - /// It is expected to return a widget to display while waiting. - /// For example: - /// ```dart - /// (BuildContext context) { - /// return Text('Loading...'); - /// } - /// ``` - /// - /// If not specified, a [CircularProgressIndicator](https://docs.flutter.io/flutter/material/CircularProgressIndicator-class.html) is shown - final WidgetBuilder? loadingBuilder; - - /// Called when [suggestionsCallback] returns an empty array. - /// - /// It is expected to return a widget to display when no suggestions are - /// available. - /// For example: - /// ```dart - /// (BuildContext context) { - /// return Text('No Items Found!'); - /// } - /// ``` - /// - /// If not specified, a simple text is shown - final WidgetBuilder? noItemsFoundBuilder; - - /// Called when [suggestionsCallback] throws an exception. - /// - /// It is called with the error object, and expected to return a widget to - /// display when an exception is thrown - /// For example: - /// ```dart - /// (BuildContext context, error) { - /// return Text('$error'); - /// } - /// ``` - /// - /// If not specified, the error is shown in [ThemeData.errorColor](https://docs.flutter.io/flutter/material/ThemeData/errorColor.html) - final ErrorBuilder? errorBuilder; - - /// Called to display animations when [suggestionsCallback] returns suggestions - /// - /// It is provided with the suggestions box instance and the animation - /// controller, and expected to return some animation that uses the controller - /// to display the suggestion box. - /// - /// For example: - /// ```dart - /// transitionBuilder: (context, suggestionsBox, animationController) { - /// return FadeTransition( - /// child: suggestionsBox, - /// opacity: CurvedAnimation( - /// parent: animationController, - /// curve: Curves.fastOutSlowIn - /// ), - /// ); - /// } - /// ``` - /// This argument is best used with [animationDuration] and [animationStart] - /// to fully control the animation. - /// - /// To fully remove the animation, just return `suggestionsBox` - /// - /// If not specified, a [SizeTransition](https://docs.flutter.io/flutter/widgets/SizeTransition-class.html) is shown. - final AnimationTransitionBuilder? transitionBuilder; - - /// The duration that [transitionBuilder] animation takes. - /// - /// This argument is best used with [transitionBuilder] and [animationStart] - /// to fully control the animation. - /// - /// Defaults to 500 milliseconds. - final Duration animationDuration; - - /// Determine the [SuggestionBox]'s direction. - /// - /// If [AxisDirection.down], the [SuggestionBox] will be below the [TextField] - /// and the [_SuggestionsList] will grow **down**. - /// - /// If [AxisDirection.up], the [SuggestionBox] will be above the [TextField] - /// and the [_SuggestionsList] will grow **up**. - /// - /// [AxisDirection.left] and [AxisDirection.right] are not allowed. - final AxisDirection direction; - - /// The value at which the [transitionBuilder] animation starts. - /// - /// This argument is best used with [transitionBuilder] and [animationDuration] - /// to fully control the animation. - /// - /// Defaults to 0.25. - final double animationStart; - - /// The configuration of the [TextField](https://docs.flutter.io/flutter/material/TextField-class.html) - /// that the TypeAhead widget displays - final TextFieldConfiguration textFieldConfiguration; - - /// How far below the text field should the suggestions box be - /// - /// Defaults to 5.0 - final double suggestionsBoxVerticalOffset; - - /// If set to true, suggestions will be fetched immediately when the field is - /// added to the view. - /// - /// But the suggestions box will only be shown when the field receives focus. - /// To make the field receive focus immediately, you can set the `autofocus` - /// property in the [textFieldConfiguration] to true - /// - /// Defaults to false - final bool getImmediateSuggestions; - - /// If set to true, no loading box will be shown while suggestions are - /// being fetched. [loadingBuilder] will also be ignored. - /// - /// Defaults to false. - final bool hideOnLoading; - - /// If set to true, nothing will be shown if there are no results. - /// [noItemsFoundBuilder] will also be ignored. - /// - /// Defaults to false. - final bool hideOnEmpty; - - /// If set to true, nothing will be shown if there is an error. - /// [errorBuilder] will also be ignored. - /// - /// Defaults to false. - final bool hideOnError; - - /// If set to false, the suggestions box will stay opened after - /// the keyboard is closed. - /// - /// Defaults to true. - final bool hideSuggestionsOnKeyboardHide; - - /// If set to false, the suggestions box will show a circular - /// progress indicator when retrieving suggestions. - /// - /// Defaults to true. - final bool keepSuggestionsOnLoading; - - /// If set to true, the suggestions box will remain opened even after - /// selecting a suggestion. - /// - /// Note that if this is enabled, the only way - /// to close the suggestions box is either manually via the - /// `SuggestionsBoxController` or when the user closes the software - /// keyboard if `hideSuggestionsOnKeyboardHide` is set to true. Users - /// with a physical keyboard will be unable to close the - /// box without a manual way via `SuggestionsBoxController`. - /// - /// Defaults to false. - final bool keepSuggestionsOnSuggestionSelected; - - /// If set to true, in the case where the suggestions box has less than - /// _SuggestionsBoxController.minOverlaySpace to grow in the desired [direction], the direction axis - /// will be temporarily flipped if there's more room available in the opposite - /// direction. - /// - /// Defaults to false - final bool autoFlipDirection; - - final SelectionToTextTransformer? selectionToTextTransformer; - - /// Controls the text being edited. - /// - /// If null, this widget will create its own [TextEditingController]. - final TextEditingController? controller; - - final bool hideKeyboard; - - final ScrollController? scrollController; - - /// Creates text field that auto-completes user input from a list of items - FormBuilderTypeAhead({ - Key? key, - //From Super - AutovalidateMode autovalidateMode = AutovalidateMode.disabled, - bool enabled = true, - FocusNode? focusNode, - FormFieldSetter? onSaved, - FormFieldValidator? validator, - InputDecoration decoration = const InputDecoration(), - required String name, - required this.itemBuilder, - required this.suggestionsCallback, - T? initialValue, - ValueChanged? onChanged, - ValueTransformer? valueTransformer, - VoidCallback? onReset, - this.animationDuration = const Duration(milliseconds: 500), - this.animationStart = 0.25, - this.autoFlipDirection = false, - this.controller, - this.debounceDuration = const Duration(milliseconds: 300), - this.direction = AxisDirection.down, - this.errorBuilder, - this.getImmediateSuggestions = false, - this.hideKeyboard = false, - this.hideOnEmpty = false, - this.hideOnError = false, - this.hideOnLoading = false, - this.hideSuggestionsOnKeyboardHide = true, - this.keepSuggestionsOnLoading = true, - this.keepSuggestionsOnSuggestionSelected = false, - this.loadingBuilder, - this.noItemsFoundBuilder, - this.onSuggestionSelected, - this.scrollController, - this.selectionToTextTransformer, - this.suggestionsBoxController, - this.suggestionsBoxDecoration = const SuggestionsBoxDecoration(), - this.suggestionsBoxVerticalOffset = 5.0, - this.textFieldConfiguration = const TextFieldConfiguration(), - this.transitionBuilder, - }) : assert(T == String || selectionToTextTransformer != null), - super( - key: key, - initialValue: initialValue, - name: name, - validator: validator, - valueTransformer: valueTransformer, - onChanged: onChanged, - autovalidateMode: autovalidateMode, - onSaved: onSaved, - enabled: enabled, - onReset: onReset, - decoration: decoration, - focusNode: focusNode, - builder: (FormFieldState field) { - final state = field as FormBuilderTypeAheadState; - final theme = Theme.of(state.context); - - return TypeAheadField( - textFieldConfiguration: textFieldConfiguration.copyWith( - enabled: state.enabled, - controller: state._typeAheadController, - style: state.enabled - ? textFieldConfiguration.style - : theme.textTheme.titleMedium!.copyWith( - color: theme.disabledColor, - ), - focusNode: state.effectiveFocusNode, - decoration: state.decoration, - ), - // TODO HACK to satisfy strictness - suggestionsCallback: suggestionsCallback, - itemBuilder: itemBuilder, - transitionBuilder: (context, suggestionsBox, controller) => - suggestionsBox, - onSuggestionSelected: (T suggestion) { - state.didChange(suggestion); - onSuggestionSelected?.call(suggestion); - }, - getImmediateSuggestions: getImmediateSuggestions, - errorBuilder: errorBuilder, - noItemsFoundBuilder: noItemsFoundBuilder, - loadingBuilder: loadingBuilder, - debounceDuration: debounceDuration, - suggestionsBoxDecoration: suggestionsBoxDecoration, - suggestionsBoxVerticalOffset: suggestionsBoxVerticalOffset, - animationDuration: animationDuration, - animationStart: animationStart, - direction: direction, - hideOnLoading: hideOnLoading, - hideOnEmpty: hideOnEmpty, - hideOnError: hideOnError, - hideSuggestionsOnKeyboardHide: hideSuggestionsOnKeyboardHide, - keepSuggestionsOnLoading: keepSuggestionsOnLoading, - autoFlipDirection: autoFlipDirection, - suggestionsBoxController: suggestionsBoxController, - keepSuggestionsOnSuggestionSelected: - keepSuggestionsOnSuggestionSelected, - hideKeyboard: hideKeyboard, - scrollController: scrollController, - ); - }, - ); - - @override - FormBuilderTypeAheadState createState() => FormBuilderTypeAheadState(); -} - -class FormBuilderTypeAheadState - extends FormBuilderFieldState, T> { - late TextEditingController _typeAheadController; - - @override - void initState() { - super.initState(); - _typeAheadController = widget.controller ?? - TextEditingController(text: _getTextString(initialValue)); - // _typeAheadController.addListener(_handleControllerChanged); - } - - // void _handleControllerChanged() { - // Suppress changes that originated from within this class. - // - // In the case where a controller has been passed in to this widget, we - // register this change listener. In these cases, we'll also receive change - // notifications for changes originating from within this class -- for - // example, the reset() method. In such cases, the FormField value will - // already have been set. - // if (_typeAheadController.text != value) { - // didChange(_typeAheadController.text as T); - // } - // } - - @override - void didChange(T? value) { - super.didChange(value); - var text = _getTextString(value); - - if (_typeAheadController.text != text) { - _typeAheadController.text = text; - } - } - - @override - void dispose() { - // Dispose the _typeAheadController when initState created it - super.dispose(); - _typeAheadController.dispose(); - } - - @override - void reset() { - super.reset(); - - _typeAheadController.text = _getTextString(initialValue); - } - - String _getTextString(T? value) { - var text = value == null - ? '' - : widget.selectionToTextTransformer != null - ? widget.selectionToTextTransformer!(value) - : value.toString(); - - return text; - } -} diff --git a/lib/core/widgets/material/chips_input.dart b/lib/core/widgets/material/chips_input.dart deleted file mode 100644 index 7369a0c..0000000 --- a/lib/core/widgets/material/chips_input.dart +++ /dev/null @@ -1,288 +0,0 @@ -// MIT License -// -// Copyright (c) 2019 Simon Lightfoot -// -// Permission is hereby granted, free of charge, to any person obtaining a copy -// of this software and associated documentation files (the "Software"), to deal -// in the Software without restriction, including without limitation the rights -// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell -// copies of the Software, and to permit persons to whom the Software is -// furnished to do so, subject to the following conditions: -// -// The above copyright notice and this permission notice shall be included in all -// copies or substantial portions of the Software. -// -// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, -// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE -// SOFTWARE. -// -import 'dart:async'; - -import 'package:flutter/material.dart'; -import 'package:flutter/services.dart'; - -typedef ChipsInputSuggestions = Future> Function(String query); -typedef ChipSelected = void Function(T data, bool selected); -typedef ChipsBuilder = Widget Function( - BuildContext context, ChipsInputState state, T data); - -class ChipsInput extends StatefulWidget { - const ChipsInput({ - super.key, - this.decoration = const InputDecoration(), - required this.chipBuilder, - required this.suggestionBuilder, - required this.findSuggestions, - required this.onChanged, - this.onChipTapped, - }); - - final InputDecoration decoration; - final ChipsInputSuggestions findSuggestions; - final ValueChanged> onChanged; - final ValueChanged? onChipTapped; - final ChipsBuilder chipBuilder; - final ChipsBuilder suggestionBuilder; - - @override - ChipsInputState createState() => ChipsInputState(); -} - -class ChipsInputState extends State> { - static const kObjectReplacementChar = 0xFFFC; - - Set _chips = {}; - List _suggestions = []; - int _searchId = 0; - - FocusNode _focusNode = FocusNode(); - TextEditingValue _value = const TextEditingValue(); - TextInputConnection? _connection; - - String get text { - return String.fromCharCodes( - _value.text.codeUnits.where((ch) => ch != kObjectReplacementChar), - ); - } - - TextEditingValue get currentTextEditingValue => _value; - - bool get _hasInputConnection => - _connection != null && (_connection?.attached ?? false); - - void requestKeyboard() { - if (_focusNode.hasFocus) { - _openInputConnection(); - } else { - FocusScope.of(context).requestFocus(_focusNode); - } - } - - void selectSuggestion(T data) { - setState(() { - _chips.add(data); - _updateTextInputState(); - _suggestions = []; - }); - widget.onChanged(_chips.toList(growable: false)); - } - - void deleteChip(T data) { - setState(() { - _chips.remove(data); - _updateTextInputState(); - }); - widget.onChanged(_chips.toList(growable: false)); - } - - @override - void initState() { - super.initState(); - _focusNode = FocusNode(); - _focusNode.addListener(_onFocusChanged); - } - - void _onFocusChanged() { - if (_focusNode.hasFocus) { - _openInputConnection(); - } else { - _closeInputConnectionIfNeeded(); - } - setState(() { - // rebuild so that _TextCursor is hidden. - }); - } - - @override - void dispose() { - _focusNode.dispose(); - _closeInputConnectionIfNeeded(); - super.dispose(); - } - - void _openInputConnection() { - if (!_hasInputConnection) { - _connection?.setEditingState(_value); - } - _connection?.show(); - } - - void _closeInputConnectionIfNeeded() { - if (_hasInputConnection) { - _connection?.close(); - _connection = null; - } - } - - @override - Widget build(BuildContext context) { - var chipsChildren = _chips - .map( - (data) => widget.chipBuilder(context, this, data), - ) - .toList(); - - final theme = Theme.of(context); - - chipsChildren.add( - SizedBox( - height: 32.0, - child: Row( - mainAxisSize: MainAxisSize.min, - crossAxisAlignment: CrossAxisAlignment.stretch, - children: [ - Text( - text, - style: theme.textTheme.bodyLarge?.copyWith( - height: 1.5, - ), - ), - _TextCaret( - resumed: _focusNode.hasFocus, - ), - ], - ), - ), - ); - - return Column( - crossAxisAlignment: CrossAxisAlignment.stretch, - //mainAxisSize: MainAxisSize.min, - children: [ - GestureDetector( - behavior: HitTestBehavior.opaque, - onTap: requestKeyboard, - child: InputDecorator( - decoration: widget.decoration, - isFocused: _focusNode.hasFocus, - isEmpty: _value.text.isEmpty, - child: Wrap( - children: chipsChildren, - spacing: 4.0, - runSpacing: 4.0, - ), - ), - ), - Expanded( - child: ListView.builder( - itemCount: _suggestions.length, - itemBuilder: (BuildContext context, int index) { - return widget.suggestionBuilder( - context, this, _suggestions[index]); - }, - ), - ), - ], - ); - } - - void updateEditingValue(TextEditingValue value) { - final oldCount = _countReplacements(_value); - final newCount = _countReplacements(value); - setState(() { - if (newCount < oldCount) { - _chips = Set.from(_chips.take(newCount)); - } - _value = value; - }); - _onSearchChanged(text); - } - - int _countReplacements(TextEditingValue value) { - return value.text.codeUnits - .where((ch) => ch == kObjectReplacementChar) - .length; - } - - void _updateTextInputState() { - final text = - String.fromCharCodes(_chips.map((_) => kObjectReplacementChar)); - _value = TextEditingValue( - text: text, - selection: TextSelection.collapsed(offset: text.length), - composing: TextRange(start: 0, end: text.length), - ); - _connection?.setEditingState(_value); - } - - void _onSearchChanged(String value) async { - final localId = ++_searchId; - final results = await widget.findSuggestions(value); - if (_searchId == localId && mounted) { - setState(() => _suggestions = results - .where((profile) => !_chips.contains(profile)) - .toList(growable: false)); - } - } -} - -class _TextCaret extends StatefulWidget { - const _TextCaret({ - this.resumed = false, - }); - - final bool resumed; - - @override - _TextCursorState createState() => _TextCursorState(); -} - -class _TextCursorState extends State<_TextCaret> - with SingleTickerProviderStateMixin { - bool _displayed = false; - late Timer _timer; - - @override - void initState() { - super.initState(); - } - - void _onTimer(Timer timer) { - setState(() => _displayed = !_displayed); - } - - @override - void dispose() { - _timer.cancel(); - super.dispose(); - } - - @override - Widget build(BuildContext context) { - final theme = Theme.of(context); - return FractionallySizedBox( - heightFactor: 0.7, - child: Opacity( - opacity: _displayed && widget.resumed ? 1.0 : 0.0, - child: Container( - width: 2.0, - color: theme.primaryColor, - ), - ), - ); - } -} diff --git a/lib/features/document_bulk_action/view/widgets/bulk_edit_label_bottom_sheet.dart b/lib/features/document_bulk_action/view/widgets/bulk_edit_label_bottom_sheet.dart deleted file mode 100644 index 9f740b8..0000000 --- a/lib/features/document_bulk_action/view/widgets/bulk_edit_label_bottom_sheet.dart +++ /dev/null @@ -1,104 +0,0 @@ -import 'package:flutter/material.dart'; -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/widgets/dialog_utils/dialog_cancel_button.dart'; -import 'package:paperless_mobile/extensions/flutter_extensions.dart'; -import 'package:paperless_mobile/features/document_bulk_action/cubit/document_bulk_action_cubit.dart'; -import 'package:paperless_mobile/features/labels/view/widgets/label_form_field.dart'; -import 'package:paperless_mobile/generated/l10n/app_localizations.dart'; - -typedef LabelOptionsSelector = Map Function( - DocumentBulkActionState state); - -class BulkEditLabelBottomSheet extends StatefulWidget { - final String title; - final String formFieldLabel; - final Widget formFieldPrefixIcon; - final LabelOptionsSelector availableOptionsSelector; - final void Function(int? selectedId) onSubmit; - final int? initialValue; - final bool canCreateNewLabel; - - const BulkEditLabelBottomSheet({ - super.key, - required this.title, - required this.formFieldLabel, - required this.formFieldPrefixIcon, - required this.availableOptionsSelector, - required this.onSubmit, - this.initialValue, - required this.canCreateNewLabel, - }); - - @override - State> createState() => - _BulkEditLabelBottomSheetState(); -} - -class _BulkEditLabelBottomSheetState - extends State> { - final _formKey = GlobalKey(); - - @override - Widget build(BuildContext context) { - return Padding( - padding: - EdgeInsets.only(bottom: MediaQuery.of(context).viewInsets.bottom), - child: BlocBuilder( - builder: (context, state) { - return Padding( - padding: const EdgeInsets.all(16.0), - child: SingleChildScrollView( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - mainAxisSize: MainAxisSize.min, - children: [ - Text( - widget.title, - style: Theme.of(context).textTheme.titleLarge, - ).paddedOnly(bottom: 24), - FormBuilder( - key: _formKey, - child: LabelFormField( - initialValue: widget.initialValue != null - ? IdQueryParameter.fromId(widget.initialValue!) - : const IdQueryParameter.unset(), - canCreateNewLabel: widget.canCreateNewLabel, - name: "labelFormField", - options: widget.availableOptionsSelector(state), - labelText: widget.formFieldLabel, - prefixIcon: widget.formFieldPrefixIcon, - allowSelectUnassigned: true, - ), - ), - const SizedBox(height: 8), - Row( - mainAxisAlignment: MainAxisAlignment.end, - children: [ - const DialogCancelButton(), - const SizedBox(width: 16), - FilledButton( - onPressed: () { - if (_formKey.currentState?.saveAndValidate() ?? - false) { - final value = _formKey.currentState - ?.getRawValue('labelFormField') - as IdQueryParameter?; - widget.onSubmit(value?.maybeWhen( - fromId: (id) => id, orElse: () => null)); - } - }, - child: Text(S.of(context)!.apply), - ), - ], - ).padded(8), - ], - ), - ), - ); - }, - ), - ); - } -} diff --git a/lib/features/document_bulk_action/view/widgets/label_bulk_selection_widget.dart b/lib/features/document_bulk_action/view/widgets/label_bulk_selection_widget.dart deleted file mode 100644 index 8bde625..0000000 --- a/lib/features/document_bulk_action/view/widgets/label_bulk_selection_widget.dart +++ /dev/null @@ -1,30 +0,0 @@ -// import 'package:flutter/material.dart'; -// import 'package:flutter/src/widgets/framework.dart'; -// import 'package:flutter/src/widgets/placeholder.dart'; - -// class LabelBulkSelectionWidget extends StatelessWidget { -// final int labelId; -// final String title; -// final bool selected; -// final bool excluded; -// final Widget Function(int id) leadingWidgetBuilder; -// final void Function(int id) onSelected; -// final void Function(int id) onUnselected; -// final void Function(int id) onRemoved; - -// const LabelBulkSelectionWidget({ -// super.key, -// required this.labelId, -// required this.title, -// required this.leadingWidgetBuilder, -// required this.onSelected, -// required this.onUnselected, -// required this.onRemoved, -// }); -// @override -// Widget build(BuildContext context) { -// return ListTile( -// title: Text(title), -// ); -// } -// } diff --git a/lib/features/document_details/cubit/document_details_cubit.dart b/lib/features/document_details/cubit/document_details_cubit.dart index cf09e95..2779863 100644 --- a/lib/features/document_details/cubit/document_details_cubit.dart +++ b/lib/features/document_details/cubit/document_details_cubit.dart @@ -6,16 +6,14 @@ import 'package:flutter/material.dart'; import 'package:freezed_annotation/freezed_annotation.dart'; import 'package:open_filex/open_filex.dart'; import 'package:paperless_api/paperless_api.dart'; -import 'package:paperless_mobile/core/database/tables/local_user_account.dart'; import 'package:paperless_mobile/core/notifier/document_changed_notifier.dart'; import 'package:paperless_mobile/core/repository/label_repository.dart'; -import 'package:paperless_mobile/core/service/file_description.dart'; import 'package:paperless_mobile/core/service/file_service.dart'; import 'package:paperless_mobile/features/notifications/services/local_notification_service.dart'; import 'package:printing/printing.dart'; import 'package:share_plus/share_plus.dart'; import 'package:cross_file/cross_file.dart'; - +import 'package:path/path.dart' as p; part 'document_details_cubit.freezed.dart'; part 'document_details_state.dart'; @@ -94,11 +92,9 @@ class DocumentDetailsCubit extends Cubit { if (state.metaData == null) { await loadMetaData(); } - final desc = FileDescription.fromPath( - state.metaData!.mediaFilename.replaceAll("/", " "), - ); + final filePath = state.metaData!.mediaFilename.replaceAll("/", " "); - final fileName = "${desc.filename}.pdf"; + final fileName = "${p.basenameWithoutExtension(filePath)}.pdf"; final file = File("${cacheDir.path}/$fileName"); if (!file.existsSync()) { @@ -126,50 +122,58 @@ class DocumentDetailsCubit extends Cubit { if (state.metaData == null) { await loadMetaData(); } - String filePath = _buildDownloadFilePath( + String targetPath = _buildDownloadFilePath( downloadOriginal, await FileService.downloadsDirectory, ); - final desc = FileDescription.fromPath( - state.metaData!.mediaFilename - .replaceAll("/", " "), // Flatten directory structure - ); - if (!File(filePath).existsSync()) { - File(filePath).createSync(); + + if (!await File(targetPath).exists()) { + await File(targetPath).create(); } else { - return _notificationService.notifyFileDownload( + await _notificationService.notifyFileDownload( document: state.document, - filename: "${desc.filename}.${desc.extension}", - filePath: filePath, + filename: p.basename(targetPath), + filePath: targetPath, finished: true, locale: locale, userId: userId, ); } - await _notificationService.notifyFileDownload( - document: state.document, - filename: "${desc.filename}.${desc.extension}", - filePath: filePath, - finished: false, - locale: locale, - userId: userId, - ); + // await _notificationService.notifyFileDownload( + // document: state.document, + // filename: p.basename(targetPath), + // filePath: targetPath, + // finished: false, + // locale: locale, + // userId: userId, + // ); await _api.downloadToFile( state.document, - filePath, + targetPath, original: downloadOriginal, + onProgressChanged: (progress) { + _notificationService.notifyFileDownload( + document: state.document, + filename: p.basename(targetPath), + filePath: targetPath, + finished: true, + locale: locale, + userId: userId, + progress: progress, + ); + }, ); await _notificationService.notifyFileDownload( document: state.document, - filename: "${desc.filename}.${desc.extension}", - filePath: filePath, + filename: p.basename(targetPath), + filePath: targetPath, finished: true, locale: locale, userId: userId, ); - debugPrint("Downloaded file to $filePath"); + debugPrint("Downloaded file to $targetPath"); } Future shareDocument({bool shareOriginal = false}) async { @@ -220,12 +224,9 @@ class DocumentDetailsCubit extends Cubit { } String _buildDownloadFilePath(bool original, Directory dir) { - final description = FileDescription.fromPath( - state.metaData!.mediaFilename - .replaceAll("/", " "), // Flatten directory structure - ); - final extension = original ? description.extension : 'pdf'; - return "${dir.path}/${description.filename}.$extension"; + final normalizedPath = state.metaData!.mediaFilename.replaceAll("/", " "); + final extension = original ? p.extension(normalizedPath) : '.pdf'; + return "${dir.path}/${p.basenameWithoutExtension(normalizedPath)}$extension"; } @override diff --git a/lib/features/document_details/view/pages/document_details_page.dart b/lib/features/document_details/view/pages/document_details_page.dart index b3a1d19..2ad12d6 100644 --- a/lib/features/document_details/view/pages/document_details_page.dart +++ b/lib/features/document_details/view/pages/document_details_page.dart @@ -1,4 +1,3 @@ -import 'package:connectivity_plus/connectivity_plus.dart'; import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:go_router/go_router.dart'; @@ -48,7 +47,7 @@ class _DocumentDetailsPageState extends State { Widget build(BuildContext context) { final hasMultiUserSupport = context.watch().hasMultiUserSupport; - final tabLength = 4 + (hasMultiUserSupport ? 1 : 0); + final tabLength = 4 + (hasMultiUserSupport && false ? 1 : 0); return WillPopScope( onWillPop: () async { Navigator.of(context) @@ -86,51 +85,52 @@ class _DocumentDetailsPageState extends State { collapsedHeight: kToolbarHeight, expandedHeight: 250.0, flexibleSpace: FlexibleSpaceBar( - background: Stack( - alignment: Alignment.topCenter, - children: [ - BlocBuilder( - builder: (context, state) { - return Positioned.fill( - child: GestureDetector( - onTap: () { - DocumentPreviewRoute($extra: state.document) - .push(context); - }, - child: DocumentPreview( - document: state.document, - fit: BoxFit.cover, + background: BlocBuilder( + builder: (context, state) { + return Hero( + tag: "thumb_${state.document.id}", + child: GestureDetector( + onTap: () { + DocumentPreviewRoute($extra: state.document) + .push(context); + }, + child: Stack( + alignment: Alignment.topCenter, + children: [ + Positioned.fill( + child: DocumentPreview( + enableHero: false, + document: state.document, + fit: BoxFit.cover, + ), ), - ), - ); - }, - ), - // Positioned.fill( - // top: -kToolbarHeight, - // child: DecoratedBox( - // decoration: BoxDecoration( - // gradient: LinearGradient( - // colors: [ - // Theme.of(context) - // .colorScheme - // .background - // .withOpacity(0.8), - // Theme.of(context) - // .colorScheme - // .background - // .withOpacity(0.5), - // Colors.transparent, - // Colors.transparent, - // Colors.transparent, - // ], - // begin: Alignment.topCenter, - // end: Alignment.bottomCenter, - // ), - // ), - // ), - // ), - ], + Positioned.fill( + child: DecoratedBox( + decoration: BoxDecoration( + gradient: LinearGradient( + stops: [0.2, 0.4], + colors: [ + Theme.of(context) + .colorScheme + .background + .withOpacity(0.6), + Theme.of(context) + .colorScheme + .background + .withOpacity(0.3), + ], + begin: Alignment.topCenter, + end: Alignment.bottomCenter, + ), + ), + ), + ), + ], + ), + ), + ); + }, ), ), bottom: ColoredTabBar( @@ -177,7 +177,7 @@ class _DocumentDetailsPageState extends State { ), ), ), - if (hasMultiUserSupport) + if (hasMultiUserSupport && false) Tab( child: Text( "Permissions", @@ -266,7 +266,7 @@ class _DocumentDetailsPageState extends State { ), ], ), - if (hasMultiUserSupport) + if (hasMultiUserSupport && false) CustomScrollView( controller: _pagingScrollController, slivers: [ @@ -406,7 +406,7 @@ class _DocumentDetailsPageState extends State { if (delete) { try { await context.read().delete(document); - showSnackBar(context, S.of(context)!.documentSuccessfullyDeleted); + // showSnackBar(context, S.of(context)!.documentSuccessfullyDeleted); } on PaperlessApiException catch (error, stackTrace) { showErrorMessage(context, error, stackTrace); } finally { diff --git a/lib/features/document_edit/view/document_edit_page.dart b/lib/features/document_edit/view/document_edit_page.dart index 5743805..f974eb7 100644 --- a/lib/features/document_edit/view/document_edit_page.dart +++ b/lib/features/document_edit/view/document_edit_page.dart @@ -40,7 +40,6 @@ class _DocumentEditPageState extends State { static const fkContent = 'content'; final GlobalKey _formKey = GlobalKey(); - bool _isSubmitLoading = false; @override Widget build(BuildContext context) { @@ -314,18 +313,13 @@ class _DocumentEditPageState extends State { tags: (values[fkTags] as IdsTagsQuery?)?.include, content: values[fkContent], ); - setState(() { - _isSubmitLoading = true; - }); + try { await context.read().updateDocument(mergedDocument); showSnackBar(context, S.of(context)!.documentSuccessfullyUpdated); } on PaperlessApiException catch (error, stackTrace) { showErrorMessage(context, error, stackTrace); } finally { - setState(() { - _isSubmitLoading = false; - }); context.pop(); } } diff --git a/lib/features/document_scan/logic/services/decode.isolate.dart b/lib/features/document_scan/logic/services/decode.isolate.dart deleted file mode 100644 index 91196be..0000000 --- a/lib/features/document_scan/logic/services/decode.isolate.dart +++ /dev/null @@ -1,44 +0,0 @@ -import 'dart:io'; -import 'dart:isolate'; - -import 'package:image/image.dart' as im; - -typedef ImageOperationCallback = im.Image Function(im.Image); - -class DecodeParam { - final File file; - final SendPort sendPort; - final im.Image Function(im.Image) imageOperation; - DecodeParam(this.file, this.sendPort, this.imageOperation); -} - -void decodeIsolate(DecodeParam param) { - // Read an image from file (webp in this case). - // decodeImage will identify the format of the image and use the appropriate - // decoder. - var image = im.decodeImage(param.file.readAsBytesSync())!; - // Resize the image to a 120x? thumbnail (maintaining the aspect ratio). - var processed = param.imageOperation(image); - param.sendPort.send(processed); -} - -// Decode and process an image file in a separate thread (isolate) to avoid -// stalling the main UI thread. -Future processImage( - File file, - ImageOperationCallback imageOperation, -) async { - var receivePort = ReceivePort(); - - await Isolate.spawn( - decodeIsolate, - DecodeParam( - file, - receivePort.sendPort, - imageOperation, - )); - - var image = await receivePort.first as im.Image; - - return file.writeAsBytes(im.encodePng(image)); -} diff --git a/lib/features/document_scan/view/scanner_page.dart b/lib/features/document_scan/view/scanner_page.dart index bda703b..729c8c0 100644 --- a/lib/features/document_scan/view/scanner_page.dart +++ b/lib/features/document_scan/view/scanner_page.dart @@ -13,7 +13,6 @@ import 'package:paperless_mobile/constants.dart'; import 'package:paperless_mobile/core/config/hive/hive_config.dart'; import 'package:paperless_mobile/core/database/tables/global_settings.dart'; import 'package:paperless_mobile/core/global/constants.dart'; -import 'package:paperless_mobile/core/service/file_description.dart'; import 'package:paperless_mobile/core/service/file_service.dart'; import 'package:paperless_mobile/features/app_drawer/view/app_drawer.dart'; import 'package:paperless_mobile/features/document_scan/cubit/document_scanner_cubit.dart'; @@ -22,7 +21,6 @@ import 'package:paperless_mobile/features/document_scan/view/widgets/scanned_ima import 'package:paperless_mobile/features/document_search/view/sliver_search_bar.dart'; import 'package:paperless_mobile/features/document_upload/view/document_upload_preparation_page.dart'; import 'package:paperless_mobile/features/documents/view/pages/document_view.dart'; -import 'package:paperless_mobile/features/tasks/model/pending_tasks_notifier.dart'; import 'package:paperless_mobile/generated/l10n/app_localizations.dart'; import 'package:paperless_mobile/helpers/connectivity_aware_action_wrapper.dart'; import 'package:paperless_mobile/helpers/message_helpers.dart'; @@ -261,12 +259,12 @@ class _ScannerPageState extends State $extra: file.bytes, fileExtension: file.extension, ).push(context); - if ((uploadResult?.success ?? false) && uploadResult?.taskId != null) { + if (uploadResult?.success ?? false) { // For paperless version older than 1.11.3, task id will always be null! context.read().reset(); - context - .read() - .listenToTaskChanges(uploadResult!.taskId!); + // context + // .read() + // .listenToTaskChanges(uploadResult!.taskId!); } } @@ -350,17 +348,17 @@ class _ScannerPageState extends State void _onUploadFromFilesystem() async { FilePickerResult? result = await FilePicker.platform.pickFiles( type: FileType.custom, - allowedExtensions: supportedFileExtensions, + allowedExtensions: + supportedFileExtensions.map((e) => e.replaceAll(".", "")).toList(), withData: true, allowMultiple: false, ); if (result?.files.single.path != null) { final path = result!.files.single.path!; - final fileDescription = FileDescription.fromPath(path); + final extension = p.extension(path); + final filename = p.basenameWithoutExtension(path); File file = File(path); - if (!supportedFileExtensions.contains( - fileDescription.extension.toLowerCase(), - )) { + if (!supportedFileExtensions.contains(extension.toLowerCase())) { showErrorMessage( context, const PaperlessApiException(ErrorCode.unsupportedFileFormat), @@ -369,10 +367,15 @@ class _ScannerPageState extends State } DocumentUploadRoute( $extra: file.readAsBytesSync(), - filename: fileDescription.filename, - title: fileDescription.filename, - fileExtension: fileDescription.extension, - ).push(context); + filename: filename, + title: filename, + fileExtension: extension, + ).push(context); + // if (uploadResult.success && uploadResult.taskId != null) { + // context + // .read() + // .listenToTaskChanges(uploadResult.taskId!); + // } } } diff --git a/lib/features/document_search/cubit/document_search_cubit.dart b/lib/features/document_search/cubit/document_search_cubit.dart index 09e4ffc..657475f 100644 --- a/lib/features/document_search/cubit/document_search_cubit.dart +++ b/lib/features/document_search/cubit/document_search_cubit.dart @@ -16,6 +16,8 @@ class DocumentSearchCubit extends Cubit with DocumentPagingBlocMixin { @override final PaperlessDocumentsApi api; + @override + final ConnectivityStatusService connectivityStatusService; @override final DocumentChangedNotifier notifier; @@ -25,6 +27,7 @@ class DocumentSearchCubit extends Cubit this.api, this.notifier, this._userAppState, + this.connectivityStatusService, ) : super( DocumentSearchState( searchHistory: _userAppState.documentSearchHistory), @@ -120,9 +123,4 @@ class DocumentSearchCubit extends Cubit @override Future onFilterUpdated(DocumentFilter filter) async {} - - @override - // TODO: implement connectivityStatusService - ConnectivityStatusService get connectivityStatusService => - throw UnimplementedError(); } diff --git a/lib/features/document_search/view/document_search_bar.dart b/lib/features/document_search/view/document_search_bar.dart index 3bdd755..bbc3bd5 100644 --- a/lib/features/document_search/view/document_search_bar.dart +++ b/lib/features/document_search/view/document_search_bar.dart @@ -8,6 +8,8 @@ import 'package:paperless_mobile/features/document_search/cubit/document_search_ import 'package:paperless_mobile/features/document_search/view/document_search_page.dart'; import 'package:paperless_mobile/features/settings/view/manage_accounts_page.dart'; import 'package:paperless_mobile/features/settings/view/widgets/user_avatar.dart'; +import 'package:paperless_mobile/features/sharing/cubit/receive_share_cubit.dart'; +import 'package:paperless_mobile/features/tasks/model/pending_tasks_notifier.dart'; import 'package:paperless_mobile/generated/l10n/app_localizations.dart'; import 'package:provider/provider.dart'; @@ -51,7 +53,21 @@ class _DocumentSearchBarState extends State { crossAxisAlignment: CrossAxisAlignment.center, children: [ IconButton( - icon: const Icon(Icons.menu), + icon: ListenableBuilder( + listenable: + context.read(), + builder: (context, child) { + return Badge( + isLabelVisible: context + .read() + .pendingFiles + .isNotEmpty, + child: const Icon(Icons.menu), + backgroundColor: Colors.red, + smallSize: 8, + ); + }, + ), onPressed: Scaffold.of(context).openDrawer, ), Flexible( @@ -81,6 +97,7 @@ class _DocumentSearchBarState extends State { context.read(), Hive.box(HiveBoxes.localUserAppState) .get(context.read().id)!, + context.read(), ), child: const DocumentSearchPage(), ); @@ -95,10 +112,7 @@ class _DocumentSearchBarState extends State { onPressed: () { showDialog( context: context, - builder: (_) => Provider.value( - value: context.read(), - child: const ManageAccountsPage(), - ), + builder: (_) => const ManageAccountsPage(), ); }, ); diff --git a/lib/features/document_search/view/sliver_search_bar.dart b/lib/features/document_search/view/sliver_search_bar.dart index f0ecff1..8cea3d9 100644 --- a/lib/features/document_search/view/sliver_search_bar.dart +++ b/lib/features/document_search/view/sliver_search_bar.dart @@ -1,5 +1,4 @@ import 'package:flutter/material.dart'; -import 'package:flutter/services.dart'; import 'package:hive_flutter/adapters.dart'; import 'package:paperless_api/paperless_api.dart'; import 'package:paperless_mobile/core/config/hive/hive_config.dart'; @@ -9,7 +8,6 @@ import 'package:paperless_mobile/features/settings/view/manage_accounts_page.dar import 'package:paperless_mobile/features/settings/view/widgets/global_settings_builder.dart'; import 'package:paperless_mobile/features/settings/view/widgets/user_avatar.dart'; import 'package:provider/provider.dart'; -import 'package:sliver_tools/sliver_tools.dart'; class SliverSearchBar extends StatelessWidget { final bool floating; @@ -24,10 +22,8 @@ class SliverSearchBar extends StatelessWidget { @override Widget build(BuildContext context) { - final theme = Theme.of(context); - if (context.watch().paperlessUser.canViewDocuments) { - return SliverAppBar( + return const SliverAppBar( titleSpacing: 8, automaticallyImplyLeading: false, title: DocumentSearchBar(), diff --git a/lib/features/document_upload/cubit/document_upload_cubit.dart b/lib/features/document_upload/cubit/document_upload_cubit.dart index e1d5bec..86019a9 100644 --- a/lib/features/document_upload/cubit/document_upload_cubit.dart +++ b/lib/features/document_upload/cubit/document_upload_cubit.dart @@ -6,12 +6,13 @@ import 'package:flutter/foundation.dart'; import 'package:paperless_api/paperless_api.dart'; import 'package:paperless_mobile/core/repository/label_repository.dart'; import 'package:paperless_mobile/core/service/connectivity_status_service.dart'; +import 'package:paperless_mobile/features/tasks/model/pending_tasks_notifier.dart'; part 'document_upload_state.dart'; class DocumentUploadCubit extends Cubit { final PaperlessDocumentsApi _documentApi; - + final PendingTasksNotifier _tasksNotifier; final LabelRepository _labelRepository; final ConnectivityStatusService _connectivityStatusService; @@ -19,6 +20,7 @@ class DocumentUploadCubit extends Cubit { this._labelRepository, this._documentApi, this._connectivityStatusService, + this._tasksNotifier, ) : super(const DocumentUploadState()) { _labelRepository.addListener( this, @@ -43,7 +45,7 @@ class DocumentUploadCubit extends Cubit { DateTime? createdAt, int? asn, }) async { - return await _documentApi.create( + final taskId = await _documentApi.create( bytes, filename: filename, title: title, @@ -53,6 +55,10 @@ class DocumentUploadCubit extends Cubit { createdAt: createdAt, asn: asn, ); + if (taskId != null) { + _tasksNotifier.listenToTaskChanges(taskId); + } + return taskId; } @override diff --git a/lib/features/document_upload/view/document_upload_preparation_page.dart b/lib/features/document_upload/view/document_upload_preparation_page.dart index cb9ee11..a2113d5 100644 --- a/lib/features/document_upload/view/document_upload_preparation_page.dart +++ b/lib/features/document_upload/view/document_upload_preparation_page.dart @@ -6,7 +6,6 @@ import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:flutter_form_builder/flutter_form_builder.dart'; import 'package:go_router/go_router.dart'; import 'package:hive/hive.dart'; -import 'package:image/image.dart' as img; import 'package:intl/date_symbol_data_local.dart'; import 'package:intl/intl.dart'; import 'package:paperless_api/paperless_api.dart'; @@ -24,7 +23,6 @@ import 'package:paperless_mobile/features/labels/tags/view/widgets/tags_form_fie import 'package:paperless_mobile/features/labels/view/widgets/label_form_field.dart'; import 'package:paperless_mobile/features/sharing/view/widgets/file_thumbnail.dart'; import 'package:paperless_mobile/generated/l10n/app_localizations.dart'; - import 'package:paperless_mobile/helpers/message_helpers.dart'; import 'package:provider/provider.dart'; diff --git a/lib/features/documents/view/pages/documents_page.dart b/lib/features/documents/view/pages/documents_page.dart index d6a1e3a..9689a44 100644 --- a/lib/features/documents/view/pages/documents_page.dart +++ b/lib/features/documents/view/pages/documents_page.dart @@ -247,8 +247,13 @@ class _DocumentsPageState extends State { resizeToAvoidBottomInset: true, body: WillPopScope( onWillPop: () async { - if (context.read().state.selection.isNotEmpty) { - context.read().resetSelection(); + final cubit = context.read(); + if (cubit.state.selection.isNotEmpty) { + cubit.resetSelection(); + return false; + } + if (cubit.state.filter.appliedFiltersCount > 0 || cubit.state.filter.selectedView != null) { + await _onResetFilter(); return false; } return true; diff --git a/lib/features/documents/view/widgets/document_preview.dart b/lib/features/documents/view/widgets/document_preview.dart index f99a139..623e864 100644 --- a/lib/features/documents/view/widgets/document_preview.dart +++ b/lib/features/documents/view/widgets/document_preview.dart @@ -31,16 +31,19 @@ class DocumentPreview extends StatelessWidget { Widget build(BuildContext context) { return ConnectivityAwareActionWrapper( child: GestureDetector( + behavior: HitTestBehavior.translucent, onTap: isClickable ? () => DocumentPreviewRoute($extra: document).push(context) : null, - child: HeroMode( - enabled: enableHero, - child: Hero( - tag: "thumb_${document.id}", - child: _buildPreview(context), - ), - ), + child: Builder(builder: (context) { + if (enableHero) { + return Hero( + tag: "thumb_${document.id}", + child: _buildPreview(context), + ); + } + return _buildPreview(context); + }), ), ); } diff --git a/lib/features/documents/view/widgets/new_items_loading_widget.dart b/lib/features/documents/view/widgets/new_items_loading_widget.dart deleted file mode 100644 index 042f692..0000000 --- a/lib/features/documents/view/widgets/new_items_loading_widget.dart +++ /dev/null @@ -1,11 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:paperless_mobile/extensions/flutter_extensions.dart'; - -class NewItemsLoadingWidget extends StatelessWidget { - const NewItemsLoadingWidget({super.key}); - - @override - Widget build(BuildContext context) { - return Center(child: const CircularProgressIndicator().padded()); - } -} diff --git a/lib/features/documents/view/widgets/search/document_filter_panel.dart b/lib/features/documents/view/widgets/search/document_filter_panel.dart index 191d922..8cdb5af 100644 --- a/lib/features/documents/view/widgets/search/document_filter_panel.dart +++ b/lib/features/documents/view/widgets/search/document_filter_panel.dart @@ -6,7 +6,6 @@ import 'package:paperless_api/paperless_api.dart'; import 'package:paperless_mobile/features/documents/view/pages/documents_page.dart'; import 'package:paperless_mobile/features/documents/view/widgets/search/document_filter_form.dart'; import 'package:paperless_mobile/generated/l10n/app_localizations.dart'; -import 'package:paperless_mobile/helpers/connectivity_aware_action_wrapper.dart'; enum DateRangeSelection { before, after } diff --git a/lib/features/home/view/home_shell_widget.dart b/lib/features/home/view/home_shell_widget.dart index 4a01d1e..40bc2f0 100644 --- a/lib/features/home/view/home_shell_widget.dart +++ b/lib/features/home/view/home_shell_widget.dart @@ -1,10 +1,9 @@ import 'package:flutter/material.dart'; -import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:flutter_cache_manager/flutter_cache_manager.dart'; import 'package:hive_flutter/adapters.dart'; import 'package:paperless_api/paperless_api.dart'; import 'package:paperless_mobile/core/config/hive/hive_config.dart'; -import 'package:paperless_mobile/core/database/tables/local_user_account.dart'; +import 'package:paperless_mobile/core/config/hive/hive_extensions.dart'; import 'package:paperless_mobile/core/database/tables/local_user_app_state.dart'; import 'package:paperless_mobile/core/factory/paperless_api_factory.dart'; import 'package:paperless_mobile/core/repository/label_repository.dart'; @@ -17,14 +16,9 @@ import 'package:paperless_mobile/features/documents/cubit/documents_cubit.dart'; import 'package:paperless_mobile/features/home/view/model/api_version.dart'; import 'package:paperless_mobile/features/inbox/cubit/inbox_cubit.dart'; import 'package:paperless_mobile/features/labels/cubit/label_cubit.dart'; -import 'package:paperless_mobile/features/login/cubit/authentication_cubit.dart'; import 'package:paperless_mobile/features/saved_view/cubit/saved_view_cubit.dart'; import 'package:paperless_mobile/features/settings/view/widgets/global_settings_builder.dart'; import 'package:paperless_mobile/features/tasks/model/pending_tasks_notifier.dart'; -import 'package:paperless_mobile/routes/typed/branches/landing_route.dart'; -import 'package:paperless_mobile/routes/typed/top_level/login_route.dart'; -import 'package:paperless_mobile/routes/typed/top_level/switching_accounts_route.dart'; -import 'package:paperless_mobile/routes/typed/top_level/verify_identity_route.dart'; import 'package:provider/provider.dart'; class HomeShellWidget extends StatelessWidget { @@ -52,16 +46,16 @@ class HomeShellWidget extends StatelessWidget { return GlobalSettingsBuilder( builder: (context, settings) { final currentUserId = settings.loggedInUserId; - if (currentUserId == null) { - // This is currently the case (only for a few ms) when the current user logs out of the app. - return const SizedBox.shrink(); - } final apiVersion = ApiVersion(paperlessApiVersion); return ValueListenableBuilder( valueListenable: - Hive.box(HiveBoxes.localUserAccount) - .listenable(keys: [currentUserId]), + Hive.localUserAccountBox.listenable(keys: [currentUserId]), builder: (context, box, _) { + if (currentUserId == null) { + //This only happens during logout... + //TODO: Find way so this does not occur anymore + return SizedBox.shrink(); + } final currentLocalUser = box.get(currentUserId)!; return MultiProvider( key: ValueKey(currentUserId), @@ -181,9 +175,7 @@ class HomeShellWidget extends StatelessWidget { context.read(), context.read(), ); - if (currentLocalUser - .paperlessUser.canViewDocuments && - currentLocalUser.paperlessUser.canViewTags) { + if (currentLocalUser.paperlessUser.canViewInbox) { inboxCubit.initialize(); } return inboxCubit; diff --git a/lib/features/home/view/route_description.dart b/lib/features/home/view/route_description.dart deleted file mode 100644 index 8440e03..0000000 --- a/lib/features/home/view/route_description.dart +++ /dev/null @@ -1,41 +0,0 @@ -import 'package:flutter/material.dart'; - -class RouteDescription { - final String label; - final Icon icon; - final Icon selectedIcon; - final Widget Function(Widget icon)? badgeBuilder; - final bool enabled; - - RouteDescription({ - required this.label, - required this.icon, - required this.selectedIcon, - this.badgeBuilder, - this.enabled = true, - }); - - NavigationDestination toNavigationDestination() { - return NavigationDestination( - label: label, - icon: badgeBuilder?.call(icon) ?? icon, - selectedIcon: badgeBuilder?.call(selectedIcon) ?? selectedIcon, - ); - } - - NavigationRailDestination toNavigationRailDestination() { - return NavigationRailDestination( - label: Text(label), - icon: icon, - selectedIcon: selectedIcon, - ); - } - - BottomNavigationBarItem toBottomNavigationBarItem() { - return BottomNavigationBarItem( - label: label, - icon: badgeBuilder?.call(icon) ?? icon, - activeIcon: badgeBuilder?.call(selectedIcon) ?? selectedIcon, - ); - } -} diff --git a/lib/features/home/view/scaffold_with_navigation_bar.dart b/lib/features/home/view/scaffold_with_navigation_bar.dart index 1d8aacd..b9b2ee4 100644 --- a/lib/features/home/view/scaffold_with_navigation_bar.dart +++ b/lib/features/home/view/scaffold_with_navigation_bar.dart @@ -8,12 +8,6 @@ import 'package:paperless_mobile/features/inbox/cubit/inbox_cubit.dart'; import 'package:paperless_mobile/generated/l10n/app_localizations.dart'; import 'package:paperless_mobile/theme.dart'; -const _landingPage = 0; -const _documentsIndex = 1; -const _scannerIndex = 2; -const _labelsIndex = 3; -const _inboxIndex = 4; - class ScaffoldWithNavigationBar extends StatefulWidget { final UserModel authenticatedUser; final StatefulNavigationShell navigationShell; @@ -29,11 +23,6 @@ class ScaffoldWithNavigationBar extends StatefulWidget { } class ScaffoldWithNavigationBarState extends State { - @override - void didChangeDependencies() { - super.didChangeDependencies(); - } - @override Widget build(BuildContext context) { final theme = Theme.of(context); @@ -58,7 +47,7 @@ class ScaffoldWithNavigationBarState extends State { Icons.home, color: theme.colorScheme.primary, ), - label: S.of(context)!.home, + label: S.of(context)!.home, ), _toggleDestination( NavigationDestination( diff --git a/lib/features/home/view/widget/verify_identity_page.dart b/lib/features/home/view/widget/verify_identity_page.dart deleted file mode 100644 index f3885cb..0000000 --- a/lib/features/home/view/widget/verify_identity_page.dart +++ /dev/null @@ -1,78 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:hydrated_bloc/hydrated_bloc.dart'; -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/view/widgets/user_settings_builder.dart'; -import 'package:paperless_mobile/generated/l10n/app_localizations.dart'; - -import 'package:provider/provider.dart'; - -class VerifyIdentityPage extends StatelessWidget { - const VerifyIdentityPage({super.key}); - - @override - Widget build(BuildContext context) { - return Material( - child: Scaffold( - appBar: AppBar( - elevation: 0, - backgroundColor: Theme.of(context).colorScheme.background, - title: Text(S.of(context)!.verifyYourIdentity), - ), - body: UserAccountBuilder( - builder: (context, settings) { - if (settings == null) { - return const SizedBox.shrink(); - } - return Column( - mainAxisAlignment: MainAxisAlignment.spaceBetween, - children: [ - Text(S - .of(context)! - .useTheConfiguredBiometricFactorToAuthenticate) - .paddedSymmetrically(horizontal: 16), - const Icon( - Icons.fingerprint, - size: 96, - ), - Wrap( - alignment: WrapAlignment.spaceBetween, - runAlignment: WrapAlignment.spaceBetween, - runSpacing: 8, - spacing: 8, - children: [ - TextButton( - onPressed: () => _logout(context), - child: Text( - S.of(context)!.disconnect, - style: TextStyle( - color: Theme.of(context).colorScheme.error, - ), - ), - ), - ElevatedButton( - onPressed: () => context - .read() - .restoreSessionState(), - child: Text(S.of(context)!.verifyIdentity), - ), - ], - ).padded(16), - ], - ); - }, - ), - ), - ); - } - - void _logout(BuildContext context) { - context.read().logout(); - context.read().clear(); - context.read().clear(); - HydratedBloc.storage.clear(); - } -} diff --git a/lib/features/inbox/cubit/inbox_cubit.dart b/lib/features/inbox/cubit/inbox_cubit.dart index 5bd59a2..fa4465b 100644 --- a/lib/features/inbox/cubit/inbox_cubit.dart +++ b/lib/features/inbox/cubit/inbox_cubit.dart @@ -19,8 +19,10 @@ class InboxCubit extends HydratedCubit final LabelRepository _labelRepository; final PaperlessDocumentsApi _documentsApi; + @override final ConnectivityStatusService connectivityStatusService; + @override final DocumentChangedNotifier notifier; @@ -35,21 +37,34 @@ class InboxCubit extends HydratedCubit this._labelRepository, this.notifier, this.connectivityStatusService, - ) : super(InboxState( - labels: _labelRepository.state, - )) { + ) : super(InboxState(labels: _labelRepository.state)) { notifier.addListener( this, onDeleted: remove, onUpdated: (document) { - if (document.tags + final hasInboxTag = document.tags .toSet() .intersection(state.inboxTags.toSet()) - .isEmpty) { + .isNotEmpty; + final wasInInboxBeforeUpdate = + state.documents.map((e) => e.id).contains(document.id); + if (!hasInboxTag && wasInInboxBeforeUpdate) { + print( + "INBOX: Removing document: has: $hasInboxTag, had: $wasInInboxBeforeUpdate"); remove(document); emit(state.copyWith(itemsInInboxCount: state.itemsInInboxCount - 1)); - } else { - replace(document); + } else if (hasInboxTag) { + if (wasInInboxBeforeUpdate) { + print( + "INBOX: Replacing document: has: $hasInboxTag, had: $wasInInboxBeforeUpdate"); + replace(document); + } else { + print( + "INBOX: Adding document: has: $hasInboxTag, had: $wasInInboxBeforeUpdate"); + _addDocument(document); + emit( + state.copyWith(itemsInInboxCount: state.itemsInInboxCount + 1)); + } } }, ); @@ -61,22 +76,20 @@ class InboxCubit extends HydratedCubit ); } + @override Future initialize() async { await refreshItemsInInboxCount(false); await loadInbox(); } Future refreshItemsInInboxCount([bool shouldLoadInbox = true]) async { + debugPrint("Checking for new items in inbox..."); final stats = await _statsApi.getServerStatistics(); if (stats.documentsInInbox != state.itemsInInboxCount && shouldLoadInbox) { await loadInbox(); } - emit( - state.copyWith( - itemsInInboxCount: stats.documentsInInbox, - ), - ); + emit(state.copyWith(itemsInInboxCount: stats.documentsInInbox)); } /// @@ -85,7 +98,6 @@ class InboxCubit extends HydratedCubit Future loadInbox() async { if (!isClosed) { debugPrint("Initializing inbox..."); - final inboxTags = await _labelRepository.findAllTags().then( (tags) => tags.where((t) => t.isInboxTag).map((t) => t.id!), ); @@ -113,11 +125,22 @@ class InboxCubit extends HydratedCubit } } + Future _addDocument(DocumentModel document) async { + emit(state.copyWith( + value: [ + ...state.value, + PagedSearchResult( + count: 1, + results: [document], + ), + ], + )); + } + /// /// Fetches inbox tag ids and loads the inbox items (documents). /// Future reloadInbox() async { - emit(state.copyWith(hasLoaded: false, isLoading: true)); final inboxTags = await _labelRepository.findAllTags().then( (tags) => tags.where((t) => t.isInboxTag).map((t) => t.id!), ); @@ -134,6 +157,7 @@ class InboxCubit extends HydratedCubit } emit(state.copyWith(inboxTags: inboxTags)); updateFilter( + emitLoading: false, filter: DocumentFilter( sortField: SortField.added, tags: TagsQuery.ids(include: inboxTags.toList()), @@ -154,7 +178,7 @@ class InboxCubit extends HydratedCubit document.copyWith(tags: updatedTags), ); // Remove first so document is not replaced first. - remove(document); + // remove(document); notifier.notifyUpdated(updatedDocument); return tagsToRemove; } diff --git a/lib/features/inbox/view/pages/inbox_page.dart b/lib/features/inbox/view/pages/inbox_page.dart index 02784f3..b4efa5e 100644 --- a/lib/features/inbox/view/pages/inbox_page.dart +++ b/lib/features/inbox/view/pages/inbox_page.dart @@ -42,6 +42,7 @@ class _InboxPageState extends State @override void initState() { super.initState(); + context.read().reloadInbox(); WidgetsBinding.instance.addPostFrameCallback((_) { _nestedScrollViewKey.currentState!.innerController .addListener(_scrollExtentChangedListener); diff --git a/lib/features/labels/storage_path/view/widgets/storage_path_widget.dart b/lib/features/labels/storage_path/view/widgets/storage_path_widget.dart deleted file mode 100644 index 328b6c9..0000000 --- a/lib/features/labels/storage_path/view/widgets/storage_path_widget.dart +++ /dev/null @@ -1,35 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:paperless_api/paperless_api.dart'; - -class StoragePathWidget extends StatelessWidget { - final StoragePath? storagePath; - final Color? textColor; - final bool isClickable; - final void Function(int? id)? onSelected; - - const StoragePathWidget({ - Key? key, - this.storagePath, - this.textColor, - this.isClickable = true, - this.onSelected, - }) : super(key: key); - - @override - Widget build(BuildContext context) { - return AbsorbPointer( - absorbing: !isClickable, - child: GestureDetector( - onTap: () => onSelected?.call(storagePath?.id), - child: Text( - storagePath?.name ?? "-", - maxLines: 1, - overflow: TextOverflow.ellipsis, - style: Theme.of(context).textTheme.bodyMedium?.copyWith( - color: textColor ?? Theme.of(context).colorScheme.primary, - ), - ), - ), - ); - } -} diff --git a/lib/features/labels/tags/view/widgets/fullscreen_tags_form.dart b/lib/features/labels/tags/view/widgets/fullscreen_tags_form.dart index 37ef002..4f6c74a 100644 --- a/lib/features/labels/tags/view/widgets/fullscreen_tags_form.dart +++ b/lib/features/labels/tags/view/widgets/fullscreen_tags_form.dart @@ -1,5 +1,6 @@ import 'package:flutter/material.dart'; import 'package:paperless_api/paperless_api.dart'; +import 'package:paperless_mobile/extensions/flutter_extensions.dart'; import 'package:paperless_mobile/features/edit_label/view/impl/add_tag_page.dart'; import 'package:paperless_mobile/generated/l10n/app_localizations.dart'; @@ -68,9 +69,10 @@ class _FullscreenTagsFormState extends State { @override Widget build(BuildContext context) { + final showFab = MediaQuery.viewInsetsOf(context).bottom == 0; final theme = Theme.of(context); return Scaffold( - floatingActionButton: widget.allowCreation + floatingActionButton: widget.allowCreation && showFab ? FloatingActionButton( heroTag: "fab_tags_form", onPressed: _onAddTag, @@ -238,10 +240,16 @@ class _FullscreenTagsFormState extends State { var matches = _options .where((e) => e.name.trim().toLowerCase().contains(normalizedQuery)); if (matches.isEmpty && widget.allowCreation) { - yield Text(S.of(context)!.noItemsFound); - yield TextButton( - child: Text(S.of(context)!.addTag), - onPressed: _onAddTag, + yield Center( + child: Column( + children: [ + Text(S.of(context)!.noItemsFound).padded(), + TextButton( + child: Text(S.of(context)!.addTag), + onPressed: _onAddTag, + ), + ], + ), ); } for (final tag in matches) { diff --git a/lib/features/labels/view/widgets/fullscreen_label_form.dart b/lib/features/labels/view/widgets/fullscreen_label_form.dart index 8d827b6..c4c649e 100644 --- a/lib/features/labels/view/widgets/fullscreen_label_form.dart +++ b/lib/features/labels/view/widgets/fullscreen_label_form.dart @@ -69,6 +69,7 @@ class _FullscreenLabelFormState @override Widget build(BuildContext context) { + final showFab = MediaQuery.viewInsetsOf(context).bottom == 0; final theme = Theme.of(context); final options = _filterOptionsByQuery(_textEditingController.text); return Scaffold( @@ -124,6 +125,13 @@ class _FullscreenLabelFormState ), ), ), + floatingActionButton: showFab && widget.onCreateNewLabel != null + ? FloatingActionButton( + heroTag: "fab_label_form", + onPressed: _onCreateNewLabel, + child: const Icon(Icons.add), + ) + : null, body: Builder( builder: (context) { return Column( diff --git a/lib/features/labels/view/widgets/label_item.dart b/lib/features/labels/view/widgets/label_item.dart index e3a047f..de7e5f5 100644 --- a/lib/features/labels/view/widgets/label_item.dart +++ b/lib/features/labels/view/widgets/label_item.dart @@ -2,7 +2,6 @@ import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:paperless_api/paperless_api.dart'; import 'package:paperless_mobile/core/database/tables/local_user_account.dart'; -import 'package:paperless_mobile/core/navigation/push_routes.dart'; import 'package:paperless_mobile/helpers/format_helpers.dart'; import 'package:paperless_mobile/routes/typed/branches/labels_route.dart'; diff --git a/lib/features/labels/view/widgets/label_tab_view.dart b/lib/features/labels/view/widgets/label_tab_view.dart index d02aee6..9d6ce8c 100644 --- a/lib/features/labels/view/widgets/label_tab_view.dart +++ b/lib/features/labels/view/widgets/label_tab_view.dart @@ -76,9 +76,7 @@ class LabelTabView extends StatelessWidget { Text( translateMatchingAlgorithmName( context, l.matchingAlgorithm) + - ((l.match?.isNotEmpty ?? false) - ? ": ${l.match}" - : ""), + (l.match.isNotEmpty ? ": ${l.match}" : ""), maxLines: 2, ), onOpenEditPage: canEdit ? onEdit : null, diff --git a/lib/features/landing/view/landing_page.dart b/lib/features/landing/view/landing_page.dart index e453853..0f5d18f 100644 --- a/lib/features/landing/view/landing_page.dart +++ b/lib/features/landing/view/landing_page.dart @@ -146,7 +146,7 @@ class _LandingPageState extends State { shape: Theme.of(context).cardTheme.shape, titleTextStyle: Theme.of(context).textTheme.labelLarge, title: Text(S.of(context)!.documentsInInbox), - onTap: currentUser.canViewTags && currentUser.canViewDocuments + onTap: currentUser.canViewInbox ? () => InboxRoute().go(context) : null, trailing: Text( @@ -161,9 +161,11 @@ class _LandingPageState extends State { shape: Theme.of(context).cardTheme.shape, titleTextStyle: Theme.of(context).textTheme.labelLarge, title: Text(S.of(context)!.totalDocuments), - onTap: () { - DocumentsRoute().go(context); - }, + onTap: currentUser.canViewDocuments + ? () { + DocumentsRoute().go(context); + } + : null, trailing: Text( stats.documentsTotal.toString(), style: Theme.of(context).textTheme.labelLarge, diff --git a/lib/features/landing/view/widgets/mime_types_pie_chart.dart b/lib/features/landing/view/widgets/mime_types_pie_chart.dart index 6d6593f..97320ba 100644 --- a/lib/features/landing/view/widgets/mime_types_pie_chart.dart +++ b/lib/features/landing/view/widgets/mime_types_pie_chart.dart @@ -1,5 +1,3 @@ -import 'dart:math'; - import 'package:fl_chart/fl_chart.dart'; import 'package:flutter/material.dart'; import 'package:paperless_api/paperless_api.dart'; diff --git a/lib/features/linked_documents/view/linked_documents_page.dart b/lib/features/linked_documents/view/linked_documents_page.dart index 37ab278..5a7fdba 100644 --- a/lib/features/linked_documents/view/linked_documents_page.dart +++ b/lib/features/linked_documents/view/linked_documents_page.dart @@ -1,8 +1,6 @@ import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; -import 'package:go_router/go_router.dart'; import 'package:paperless_mobile/core/bloc/connectivity_cubit.dart'; -import 'package:paperless_mobile/core/navigation/push_routes.dart'; import 'package:paperless_mobile/features/documents/view/widgets/adaptive_documents_view.dart'; import 'package:paperless_mobile/features/documents/view/widgets/selection/view_type_selection_widget.dart'; import 'package:paperless_mobile/features/linked_documents/cubit/linked_documents_cubit.dart'; diff --git a/lib/features/login/cubit/authentication_cubit.dart b/lib/features/login/cubit/authentication_cubit.dart index 9d078b1..3971acb 100644 --- a/lib/features/login/cubit/authentication_cubit.dart +++ b/lib/features/login/cubit/authentication_cubit.dart @@ -1,11 +1,9 @@ -import 'package:connectivity_plus/connectivity_plus.dart'; import 'package:dio/dio.dart'; +import 'package:equatable/equatable.dart'; import 'package:flutter/widgets.dart'; -import 'package:freezed_annotation/freezed_annotation.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/connectivity_cubit.dart'; import 'package:paperless_mobile/core/config/hive/hive_config.dart'; import 'package:paperless_mobile/core/config/hive/hive_extensions.dart'; import 'package:paperless_mobile/core/database/tables/global_settings.dart'; @@ -14,6 +12,7 @@ import 'package:paperless_mobile/core/database/tables/local_user_app_state.dart' import 'package:paperless_mobile/core/database/tables/local_user_settings.dart'; import 'package:paperless_mobile/core/database/tables/user_credentials.dart'; import 'package:paperless_mobile/core/factory/paperless_api_factory.dart'; +import 'package:paperless_mobile/core/interceptor/language_header.interceptor.dart'; import 'package:paperless_mobile/core/model/info_message_exception.dart'; import 'package:paperless_mobile/core/security/session_manager.dart'; import 'package:paperless_mobile/core/service/connectivity_status_service.dart'; @@ -22,21 +21,26 @@ 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/reachability_status.dart'; import 'package:paperless_mobile/features/login/services/authentication_service.dart'; +import 'package:paperless_mobile/features/notifications/services/local_notification_service.dart'; import 'package:paperless_mobile/generated/l10n/app_localizations.dart'; part 'authentication_state.dart'; +typedef _FutureVoidCallback = Future Function(); + class AuthenticationCubit extends Cubit { final LocalAuthenticationService _localAuthService; final PaperlessApiFactory _apiFactory; final SessionManager _sessionManager; final ConnectivityStatusService _connectivityService; + final LocalNotificationService _notificationService; AuthenticationCubit( this._localAuthService, this._apiFactory, this._sessionManager, this._connectivityService, + this._notificationService, ) : super(const UnauthenticatedState()); Future login({ @@ -45,7 +49,11 @@ class AuthenticationCubit extends Cubit { ClientCertificate? clientCertificate, }) async { assert(credentials.username != null && credentials.password != null); - emit(const CheckingLoginState()); + if (state is AuthenticatingState) { + // Cancel duplicate login requests + return; + } + emit(const AuthenticatingState(AuthenticatingStage.authenticating)); final localUserId = "${credentials.username}@$serverUrl"; _debugPrintMessage( "login", @@ -58,35 +66,63 @@ class AuthenticationCubit extends Cubit { credentials, clientCertificate, _sessionManager, + onFetchUserInformation: () async { + emit(const AuthenticatingState( + AuthenticatingStage.fetchingUserInformation)); + }, + onPerformLogin: () async { + emit(const AuthenticatingState(AuthenticatingStage.authenticating)); + }, + onPersistLocalUserData: () async { + emit(const AuthenticatingState( + AuthenticatingStage.persistingLocalUserData)); + }, ); - - // Mark logged in user as currently active user. - final globalSettings = - Hive.box(HiveBoxes.globalSettings).getValue()!; - globalSettings.loggedInUserId = localUserId; - await globalSettings.save(); - - emit(AuthenticatedState(localUserId: localUserId)); - _debugPrintMessage( - "login", - "User successfully logged in.", + } catch (e) { + emit( + AuthenticationErrorState( + serverUrl: serverUrl, + username: credentials.username!, + password: credentials.password!, + clientCertificate: clientCertificate, + ), ); - } catch (error) { - emit(const UnauthenticatedState()); + rethrow; } + + // Mark logged in user as currently active user. + final globalSettings = + Hive.box(HiveBoxes.globalSettings).getValue()!; + globalSettings.loggedInUserId = localUserId; + await globalSettings.save(); + + emit(AuthenticatedState(localUserId: localUserId)); + _debugPrintMessage( + "login", + "User successfully logged in.", + ); } /// Switches to another account if it exists. Future switchAccount(String localUserId) async { emit(const SwitchingAccountsState()); + _debugPrintMessage( + "switchAccount", + "Trying to switch to user $localUserId...", + ); + final globalSettings = Hive.box(HiveBoxes.globalSettings).getValue()!; - if (globalSettings.loggedInUserId == localUserId) { - emit(AuthenticatedState(localUserId: localUserId)); - return; - } - final userAccountBox = - Hive.box(HiveBoxes.localUserAccount); + // if (globalSettings.loggedInUserId == localUserId) { + // _debugPrintMessage( + // "switchAccount", + // "User $localUserId is already logged in.", + // ); + // emit(AuthenticatedState(localUserId: localUserId)); + // return; + // } + + final userAccountBox = Hive.localUserAccountBox; if (!userAccountBox.containsKey(localUserId)) { debugPrint("User $localUserId not yet registered."); @@ -99,10 +135,18 @@ class AuthenticationCubit extends Cubit { final authenticated = await _localAuthService .authenticateLocalUser("Authenticate to switch your account."); if (!authenticated) { - debugPrint("User not authenticated."); + _debugPrintMessage( + "switchAccount", + "User could not be authenticated.", + ); + emit(VerifyIdentityState(userId: localUserId)); return; } } + final currentlyLoggedInUser = globalSettings.loggedInUserId; + if (currentlyLoggedInUser != localUserId) { + await _notificationService.cancelUserNotifications(localUserId); + } await withEncryptedBox( HiveBoxes.localUserCredentials, (credentialsBox) async { if (!credentialsBox.containsKey(localUserId)) { @@ -131,9 +175,7 @@ class AuthenticationCubit extends Cubit { apiVersion, ); - emit(AuthenticatedState( - localUserId: localUserId, - )); + emit(AuthenticatedState(localUserId: localUserId)); }); } @@ -142,19 +184,33 @@ class AuthenticationCubit extends Cubit { required String serverUrl, ClientCertificate? clientCertificate, required bool enableBiometricAuthentication, + required String locale, }) async { assert(credentials.password != null && credentials.username != null); final localUserId = "${credentials.username}@$serverUrl"; - final sessionManager = SessionManager(); - await _addUser( - localUserId, - serverUrl, - credentials, - clientCertificate, - sessionManager, - ); - return localUserId; + final sessionManager = SessionManager([ + LanguageHeaderInterceptor(locale), + ]); + try { + await _addUser( + localUserId, + serverUrl, + credentials, + clientCertificate, + sessionManager, + // onPerformLogin: () async { + // emit(AuthenticatingState(AuthenticatingStage.authenticating)); + // await Future.delayed(const Duration(milliseconds: 500)); + // }, + ); + + return localUserId; + } catch (error, stackTrace) { + print(error); + debugPrintStack(stackTrace: stackTrace); + rethrow; + } } Future removeAccount(String userId) async { @@ -170,28 +226,33 @@ class AuthenticationCubit extends Cubit { } /// - /// Performs a conditional hydration based on the local authentication success. + /// Restores the previous session if exists. /// - Future restoreSessionState() async { + Future restoreSession([String? userId]) async { + emit(const RestoringSessionState()); _debugPrintMessage( "restoreSessionState", "Trying to restore previous session...", ); final globalSettings = Hive.box(HiveBoxes.globalSettings).getValue()!; - final localUserId = globalSettings.loggedInUserId; - if (localUserId == null) { + final restoreSessionForUser = userId ?? globalSettings.loggedInUserId; + // final localUserId = globalSettings.loggedInUserId; + if (restoreSessionForUser == null) { _debugPrintMessage( "restoreSessionState", "There is nothing to restore.", ); + final otherAccountsExist = Hive.localUserAccountBox.isNotEmpty; // If there is nothing to restore, we can quit here. - emit(const UnauthenticatedState()); + emit( + UnauthenticatedState(redirectToAccountSelection: otherAccountsExist), + ); return; } final localUserAccountBox = Hive.box(HiveBoxes.localUserAccount); - final localUserAccount = localUserAccountBox.get(localUserId)!; + final localUserAccount = localUserAccountBox.get(restoreSessionForUser)!; _debugPrintMessage( "restoreSessionState", "Checking if biometric authentication is required...", @@ -207,7 +268,7 @@ class AuthenticationCubit extends Cubit { final localAuthSuccess = await _localAuthService.authenticateLocalUser(authenticationMesage); if (!localAuthSuccess) { - emit(const RequiresLocalAuthenticationState()); + emit(VerifyIdentityState(userId: restoreSessionForUser)); _debugPrintMessage( "restoreSessionState", "User could not be authenticated.", @@ -231,7 +292,7 @@ class AuthenticationCubit extends Cubit { final authentication = await withEncryptedBox( HiveBoxes.localUserCredentials, (box) { - return box.get(globalSettings.loggedInUserId!); + return box.get(restoreSessionForUser); }); if (authentication == null) { @@ -290,8 +351,9 @@ class AuthenticationCubit extends Cubit { "Skipping update of server user (server could not be reached).", ); } - - emit(AuthenticatedState(localUserId: localUserId)); + globalSettings.loggedInUserId = restoreSessionForUser; + await globalSettings.save(); + emit(AuthenticatedState(localUserId: restoreSessionForUser)); _debugPrintMessage( "restoreSessionState", @@ -300,7 +362,7 @@ class AuthenticationCubit extends Cubit { } Future logout([bool removeAccount = false]) async { - emit(const LogginOutState()); + emit(const LoggingOutState()); _debugPrintMessage( "logout", "Trying to log out current user...", @@ -308,13 +370,16 @@ class AuthenticationCubit extends Cubit { await _resetExternalState(); final globalSettings = Hive.globalSettingsBox.getValue()!; final userId = globalSettings.loggedInUserId!; + await _notificationService.cancelUserNotifications(userId); + + final otherAccountsExist = Hive.localUserAccountBox.length > 1; + emit(UnauthenticatedState(redirectToAccountSelection: otherAccountsExist)); if (removeAccount) { - this.removeAccount(userId); + await this.removeAccount(userId); } globalSettings.loggedInUserId = null; await globalSettings.save(); - emit(const UnauthenticatedState()); _debugPrintMessage( "logout", "User successfully logged out.", @@ -322,16 +387,8 @@ class AuthenticationCubit extends Cubit { } Future _resetExternalState() async { - _debugPrintMessage( - "_resetExternalState", - "Resetting session manager and clearing storage...", - ); _sessionManager.resetSettings(); await HydratedBloc.storage.clear(); - _debugPrintMessage( - "_resetExternalState", - "Session manager successfully reset and storage cleared.", - ); } Future _addUser( @@ -339,8 +396,11 @@ class AuthenticationCubit extends Cubit { String serverUrl, LoginFormCredentials credentials, ClientCertificate? clientCert, - SessionManager sessionManager, - ) async { + SessionManager sessionManager, { + _FutureVoidCallback? onPerformLogin, + _FutureVoidCallback? onPersistLocalUserData, + _FutureVoidCallback? onFetchUserInformation, + }) async { assert(credentials.username != null && credentials.password != null); _debugPrintMessage("_addUser", "Adding new user $localUserId..."); @@ -356,6 +416,8 @@ class AuthenticationCubit extends Cubit { "Trying to login user ${credentials.username} on $serverUrl...", ); + await onPerformLogin?.call(); + final token = await authApi.login( username: credentials.username!, password: credentials.password!, @@ -384,6 +446,7 @@ class AuthenticationCubit extends Cubit { ); throw InfoMessageException(code: ErrorCode.userAlreadyExists); } + await onFetchUserInformation?.call(); final apiVersion = await _getApiVersion(sessionManager.client); _debugPrintMessage( "_addUser", @@ -413,6 +476,7 @@ class AuthenticationCubit extends Cubit { "_addUser", "Persisting local user account...", ); + await onPersistLocalUserData?.call(); // Create user account await userAccountBox.put( localUserId, @@ -490,7 +554,7 @@ class AuthenticationCubit extends Cubit { "API version ($apiVersion) successfully retrieved.", ); return apiVersion; - } on DioException catch (e) { + } on DioException catch (_) { return defaultValue; } } diff --git a/lib/features/login/cubit/authentication_state.dart b/lib/features/login/cubit/authentication_state.dart index bc4d29c..1ad5fab 100644 --- a/lib/features/login/cubit/authentication_state.dart +++ b/lib/features/login/cubit/authentication_state.dart @@ -7,34 +7,76 @@ sealed class AuthenticationState { switch (this) { AuthenticatedState() => true, _ => false }; } -class UnauthenticatedState extends AuthenticationState { - const UnauthenticatedState(); +class UnauthenticatedState extends AuthenticationState with EquatableMixin { + final bool redirectToAccountSelection; + + const UnauthenticatedState({this.redirectToAccountSelection = false}); + + @override + List get props => [redirectToAccountSelection]; } -class RequiresLocalAuthenticationState extends AuthenticationState { - const RequiresLocalAuthenticationState(); +class RestoringSessionState extends AuthenticationState { + const RestoringSessionState(); } -class CheckingLoginState extends AuthenticationState { - const CheckingLoginState(); +class VerifyIdentityState extends AuthenticationState { + final String userId; + const VerifyIdentityState({required this.userId}); } -class LogginOutState extends AuthenticationState { - const LogginOutState(); +class AuthenticatingState extends AuthenticationState with EquatableMixin { + final AuthenticatingStage currentStage; + const AuthenticatingState(this.currentStage); + + @override + List get props => [currentStage]; } -class AuthenticatedState extends AuthenticationState { +class LoggingOutState extends AuthenticationState { + const LoggingOutState(); +} + +class AuthenticatedState extends AuthenticationState with EquatableMixin { final String localUserId; - const AuthenticatedState({ - required this.localUserId, - }); + const AuthenticatedState({required this.localUserId}); + + @override + List get props => [localUserId]; } class SwitchingAccountsState extends AuthenticationState { const SwitchingAccountsState(); } -class AuthenticationErrorState extends AuthenticationState { - const AuthenticationErrorState(); +class AuthenticationErrorState extends AuthenticationState with EquatableMixin { + final ErrorCode? errorCode; + final String serverUrl; + final ClientCertificate? clientCertificate; + final String username; + final String password; + + const AuthenticationErrorState({ + this.errorCode, + required this.serverUrl, + this.clientCertificate, + required this.username, + required this.password, + }); + + @override + List get props => [ + errorCode, + serverUrl, + clientCertificate, + username, + password, + ]; +} + +enum AuthenticatingStage { + authenticating, + persistingLocalUserData, + fetchingUserInformation, } diff --git a/lib/features/login/cubit/old_authentication_state.dart b/lib/features/login/cubit/old_authentication_state.dart deleted file mode 100644 index a2bd806..0000000 --- a/lib/features/login/cubit/old_authentication_state.dart +++ /dev/null @@ -1,48 +0,0 @@ -import 'package:equatable/equatable.dart'; - -class OldAuthenticationState with EquatableMixin { - final bool showBiometricAuthenticationScreen; - final bool isAuthenticated; - final String? username; - final String? fullName; - final String? localUserId; - final int? apiVersion; - - const OldAuthenticationState({ - this.isAuthenticated = false, - this.showBiometricAuthenticationScreen = false, - this.username, - this.fullName, - this.localUserId, - this.apiVersion, - }); - - OldAuthenticationState copyWith({ - bool? isAuthenticated, - bool? showBiometricAuthenticationScreen, - String? username, - String? fullName, - String? localUserId, - int? apiVersion, - }) { - return OldAuthenticationState( - isAuthenticated: isAuthenticated ?? this.isAuthenticated, - showBiometricAuthenticationScreen: showBiometricAuthenticationScreen ?? - this.showBiometricAuthenticationScreen, - username: username ?? this.username, - fullName: fullName ?? this.fullName, - localUserId: localUserId ?? this.localUserId, - apiVersion: apiVersion ?? this.apiVersion, - ); - } - - @override - List get props => [ - localUserId, - username, - fullName, - isAuthenticated, - showBiometricAuthenticationScreen, - apiVersion, - ]; -} diff --git a/lib/features/login/model/client_certificate.dart b/lib/features/login/model/client_certificate.dart index 8c920ba..00c24c8 100644 --- a/lib/features/login/model/client_certificate.dart +++ b/lib/features/login/model/client_certificate.dart @@ -12,5 +12,9 @@ class ClientCertificate { @HiveField(1) String? passphrase; - ClientCertificate({required this.bytes, this.passphrase}); + + ClientCertificate({ + required this.bytes, + this.passphrase, + }); } diff --git a/lib/features/login/model/client_certificate_form_model.dart b/lib/features/login/model/client_certificate_form_model.dart index afb9ddb..b168d15 100644 --- a/lib/features/login/model/client_certificate_form_model.dart +++ b/lib/features/login/model/client_certificate_form_model.dart @@ -7,9 +7,16 @@ class ClientCertificateFormModel { final Uint8List bytes; final String? passphrase; - ClientCertificateFormModel({required this.bytes, this.passphrase}); + ClientCertificateFormModel({ + required this.bytes, + this.passphrase, + }); - ClientCertificateFormModel copyWith({Uint8List? bytes, String? passphrase}) { + ClientCertificateFormModel copyWith({ + Uint8List? bytes, + String? passphrase, + String? filePath, + }) { return ClientCertificateFormModel( bytes: bytes ?? this.bytes, passphrase: passphrase ?? this.passphrase, diff --git a/lib/features/login/view/add_account_page.dart b/lib/features/login/view/add_account_page.dart index 5c48e44..e7ab5b6 100644 --- a/lib/features/login/view/add_account_page.dart +++ b/lib/features/login/view/add_account_page.dart @@ -3,27 +3,21 @@ import 'dart:async'; import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:flutter_form_builder/flutter_form_builder.dart'; -import 'package:hive_flutter/adapters.dart'; import 'package:paperless_api/paperless_api.dart'; -import 'package:paperless_mobile/core/config/hive/hive_config.dart'; -import 'package:paperless_mobile/core/database/tables/local_user_account.dart'; import 'package:paperless_mobile/core/exception/server_message_exception.dart'; import 'package:paperless_mobile/core/model/info_message_exception.dart'; -import 'package:paperless_mobile/features/login/cubit/authentication_cubit.dart'; +import 'package:paperless_mobile/core/service/connectivity_status_service.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/login_form_credentials.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'; 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/users/view/widgets/user_account_list_tile.dart'; import 'package:paperless_mobile/generated/l10n/app_localizations.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 AddAccountPage extends StatefulWidget { final FutureOr Function( BuildContext context, @@ -33,17 +27,27 @@ class AddAccountPage extends StatefulWidget { ClientCertificate? clientCertificate, ) onSubmit; - final String submitText; - final String titleString; + final String? initialServerUrl; + final String? initialUsername; + final String? initialPassword; + final ClientCertificate? initialClientCertificate; + final String submitText; + final String titleText; final bool showLocalAccounts; + final Widget? bottomLeftButton; const AddAccountPage({ Key? key, required this.onSubmit, required this.submitText, - required this.titleString, + required this.titleText, this.showLocalAccounts = false, + this.initialServerUrl, + this.initialUsername, + this.initialPassword, + this.initialClientCertificate, + this.bottomLeftButton, }) : super(key: key); @override @@ -52,86 +56,170 @@ class AddAccountPage extends StatefulWidget { class _AddAccountPageState extends State { final _formKey = GlobalKey(); + bool _isCheckingConnection = false; + ReachabilityStatus _reachabilityStatus = ReachabilityStatus.unknown; - final PageController _pageController = PageController(); - + bool _isFormSubmitted = false; @override Widget build(BuildContext context) { - return ValueListenableBuilder( - valueListenable: - Hive.box(HiveBoxes.localUserAccount).listenable(), - builder: (context, localAccounts, child) { - return Scaffold( - resizeToAvoidBottomInset: false, - body: FormBuilder( - key: _formKey, - child: PageView( - controller: _pageController, - scrollBehavior: NeverScrollableScrollBehavior(), - children: [ - if (widget.showLocalAccounts && localAccounts.isNotEmpty) - Scaffold( - appBar: AppBar( - title: Text(S.of(context)!.logInToExistingAccount), - ), - bottomNavigationBar: BottomAppBar( - child: Row( - mainAxisAlignment: MainAxisAlignment.end, - children: [ - FilledButton( - child: Text(S.of(context)!.goToLogin), - onPressed: () { - _pageController.nextPage( - duration: const Duration(milliseconds: 300), - curve: Curves.easeInOut, - ); - }, - ), - ], - ), - ), - body: ListView.builder( - itemBuilder: (context, index) { - final account = localAccounts.values.elementAt(index); - return Card( - child: UserAccountListTile( - account: account, - onTap: () { - context - .read() - .switchAccount(account.id); - }, - ), - ); - }, - itemCount: localAccounts.length, - ), - ), - ServerConnectionPage( - titleText: widget.titleString, - formBuilderKey: _formKey, - onContinue: () { - _pageController.nextPage( - duration: const Duration(milliseconds: 300), - curve: Curves.easeInOut, - ); - }, - ), - ServerLoginPage( - formBuilderKey: _formKey, - submitText: widget.submitText, - onSubmit: _login, - ), - ], + return Scaffold( + appBar: AppBar( + title: Text(widget.titleText), + ), + bottomNavigationBar: BottomAppBar( + child: Row( + mainAxisAlignment: widget.bottomLeftButton != null + ? MainAxisAlignment.spaceBetween + : MainAxisAlignment.end, + children: [ + if (widget.bottomLeftButton != null) widget.bottomLeftButton!, + FilledButton( + child: Text(S.of(context)!.loginPageSignInTitle), + onPressed: _reachabilityStatus == ReachabilityStatus.reachable && + !_isFormSubmitted + ? _onSubmit + : null, ), - ), - ); - }, + ], + ), + ), + resizeToAvoidBottomInset: true, + body: FormBuilder( + key: _formKey, + child: ListView( + children: [ + ServerAddressFormField( + initialValue: widget.initialServerUrl, + onSubmit: (address) { + _updateReachability(address); + }, + ).padded(), + ClientCertificateFormField( + initialBytes: widget.initialClientCertificate?.bytes, + initialPassphrase: widget.initialClientCertificate?.passphrase, + onChanged: (_) => _updateReachability(), + ).padded(), + _buildStatusIndicator(), + if (_reachabilityStatus == ReachabilityStatus.reachable) ...[ + UserCredentialsFormField( + formKey: _formKey, + initialUsername: widget.initialUsername, + initialPassword: widget.initialPassword, + onFieldsSubmitted: _onSubmit, + ), + Text( + S.of(context)!.loginRequiredPermissionsHint, + style: Theme.of(context).textTheme.bodySmall?.apply( + color: Theme.of(context) + .colorScheme + .onBackground + .withOpacity(0.6), + ), + ).padded(16), + ] + ], + ), + ), ); } - Future _login() async { + Future _updateReachability([String? address]) async { + setState(() { + _isCheckingConnection = true; + }); + final certForm = + _formKey.currentState?.getRawValue( + ClientCertificateFormField.fkClientCertificate, + ); + final status = await context + .read() + .isPaperlessServerReachable( + address ?? + _formKey.currentState! + .getRawValue(ServerAddressFormField.fkServerAddress), + certForm != null + ? ClientCertificate( + bytes: certForm.bytes, + passphrase: certForm.passphrase, + ) + : null, + ); + setState(() { + _isCheckingConnection = false; + _reachabilityStatus = status; + }); + } + + Widget _buildStatusIndicator() { + if (_isCheckingConnection) { + return const ListTile(); + } + + Widget _buildIconText( + IconData icon, + String text, [ + Color? color, + ]) { + return ListTile( + title: Text( + text, + style: Theme.of(context).textTheme.bodySmall?.copyWith(color: color), + ), + leading: Icon( + icon, + color: color, + ), + ); + } + + Color errorColor = Theme.of(context).colorScheme.error; + switch (_reachabilityStatus) { + case ReachabilityStatus.unknown: + return Container(); + case ReachabilityStatus.reachable: + return _buildIconText( + Icons.done, + S.of(context)!.connectionSuccessfulylEstablished, + Colors.green, + ); + case ReachabilityStatus.notReachable: + return _buildIconText( + Icons.close, + S.of(context)!.couldNotEstablishConnectionToTheServer, + errorColor, + ); + case ReachabilityStatus.unknownHost: + return _buildIconText( + Icons.close, + S.of(context)!.hostCouldNotBeResolved, + errorColor, + ); + case ReachabilityStatus.missingClientCertificate: + return _buildIconText( + Icons.close, + S.of(context)!.loginPageReachabilityMissingClientCertificateText, + errorColor, + ); + case ReachabilityStatus.invalidClientCertificateConfiguration: + return _buildIconText( + Icons.close, + S.of(context)!.incorrectOrMissingCertificatePassphrase, + errorColor, + ); + case ReachabilityStatus.connectionTimeout: + return _buildIconText( + Icons.close, + S.of(context)!.connectionTimedOut, + errorColor, + ); + } + } + + Future _onSubmit() async { FocusScope.of(context).unfocus(); + setState(() { + _isFormSubmitted = true; + }); if (_formKey.currentState?.saveAndValidate() ?? false) { final form = _formKey.currentState!.value; ClientCertificate? clientCert; @@ -162,6 +250,10 @@ class _AddAccountPageState extends State { showInfoMessage(context, error); } catch (error) { showGenericError(context, error); + } finally { + setState(() { + _isFormSubmitted = false; + }); } } } diff --git a/lib/features/login/view/login_page.dart b/lib/features/login/view/login_page.dart index 44d2e36..ffc2998 100644 --- a/lib/features/login/view/login_page.dart +++ b/lib/features/login/view/login_page.dart @@ -1,9 +1,9 @@ import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; -import 'package:go_router/go_router.dart'; import 'package:hive_flutter/adapters.dart'; import 'package:paperless_api/paperless_api.dart'; import 'package:paperless_mobile/core/config/hive/hive_config.dart'; +import 'package:paperless_mobile/core/config/hive/hive_extensions.dart'; import 'package:paperless_mobile/core/database/tables/global_settings.dart'; import 'package:paperless_mobile/core/model/info_message_exception.dart'; import 'package:paperless_mobile/features/app_intro/application_intro_slideshow.dart'; @@ -13,18 +13,41 @@ import 'package:paperless_mobile/features/login/model/login_form_credentials.dar import 'package:paperless_mobile/features/login/view/add_account_page.dart'; import 'package:paperless_mobile/generated/l10n/app_localizations.dart'; import 'package:paperless_mobile/helpers/message_helpers.dart'; -import 'package:paperless_mobile/routes/typed/branches/documents_route.dart'; +import 'package:paperless_mobile/routes/typed/top_level/login_route.dart'; class LoginPage extends StatelessWidget { - const LoginPage({super.key}); + final String? initialServerUrl; + final String? initialUsername; + final String? initialPassword; + final ClientCertificate? initialClientCertificate; + + const LoginPage({ + super.key, + this.initialServerUrl, + this.initialUsername, + this.initialPassword, + this.initialClientCertificate, + }); @override Widget build(BuildContext context) { return AddAccountPage( - titleString: S.of(context)!.connectToPaperless, + titleText: S.of(context)!.connectToPaperless, submitText: S.of(context)!.signIn, onSubmit: _onLogin, showLocalAccounts: true, + initialServerUrl: initialServerUrl, + initialUsername: initialUsername, + initialPassword: initialPassword, + initialClientCertificate: initialClientCertificate, + bottomLeftButton: Hive.localUserAccountBox.isNotEmpty + ? TextButton( + child: Text(S.of(context)!.logInToExistingAccount), + onPressed: () { + const LoginToExistingAccountRoute().go(context); + }, + ) + : null, ); } diff --git a/lib/features/login/view/login_to_existing_account_page.dart b/lib/features/login/view/login_to_existing_account_page.dart new file mode 100644 index 0000000..be2a11a --- /dev/null +++ b/lib/features/login/view/login_to_existing_account_page.dart @@ -0,0 +1,66 @@ +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_extensions.dart'; +import 'package:paperless_mobile/features/login/cubit/authentication_cubit.dart'; +import 'package:paperless_mobile/features/users/view/widgets/user_account_list_tile.dart'; +import 'package:paperless_mobile/generated/l10n/app_localizations.dart'; +import 'package:paperless_mobile/routes/typed/top_level/login_route.dart'; + +class LoginToExistingAccountPage extends StatelessWidget { + const LoginToExistingAccountPage({super.key}); + + @override + Widget build(BuildContext context) { + return ValueListenableBuilder( + valueListenable: Hive.localUserAccountBox.listenable(), + builder: (context, value, _) { + final localAccounts = value.values; + return Scaffold( + appBar: AppBar( + automaticallyImplyLeading: false, + title: Text(S.of(context)!.logInToExistingAccount), + ), + bottomNavigationBar: BottomAppBar( + child: Row( + mainAxisAlignment: MainAxisAlignment.end, + children: [ + TextButton( + child: Text(S.of(context)!.addAnotherAccount), + onPressed: () { + const LoginRoute().go(context); + }, + ), + ], + ), + ), + body: ListView.builder( + itemBuilder: (context, index) { + final account = localAccounts.elementAt(index); + return Card( + child: UserAccountListTile( + account: account, + onTap: () { + context + .read() + .switchAccount(account.id); + }, + trailing: IconButton( + tooltip: S.of(context)!.remove, + icon: Icon(Icons.close), + onPressed: () { + context + .read() + .removeAccount(account.id); + }, + ), + ), + ); + }, + itemCount: localAccounts.length, + ), + ); + }, + ); + } +} diff --git a/lib/features/login/view/verify_identity_page.dart b/lib/features/login/view/verify_identity_page.dart new file mode 100644 index 0000000..cd482da --- /dev/null +++ b/lib/features/login/view/verify_identity_page.dart @@ -0,0 +1,64 @@ +import 'package:flutter/material.dart'; +import 'package:paperless_mobile/extensions/flutter_extensions.dart'; +import 'package:paperless_mobile/features/login/cubit/authentication_cubit.dart'; +import 'package:paperless_mobile/generated/l10n/app_localizations.dart'; +import 'package:paperless_mobile/routes/typed/top_level/login_route.dart'; +import 'package:provider/provider.dart'; + +class VerifyIdentityPage extends StatelessWidget { + final String userId; + const VerifyIdentityPage({super.key, required this.userId}); + + @override + Widget build(BuildContext context) { + return Material( + child: Scaffold( + appBar: AppBar( + elevation: 0, + backgroundColor: Theme.of(context).colorScheme.background, + title: Text(S.of(context)!.verifyYourIdentity), + ), + bottomNavigationBar: BottomAppBar( + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + TextButton( + onPressed: () { + const LoginToExistingAccountRoute().go(context); + }, + child: Text(S.of(context)!.goToLogin), + ), + FilledButton( + onPressed: () => + context.read().restoreSession(userId), + child: Text(S.of(context)!.verifyIdentity), + ), + ], + ), + ), + body: Column( + mainAxisAlignment: MainAxisAlignment.spaceAround, + children: [ + Text( + S.of(context)!.useTheConfiguredBiometricFactorToAuthenticate, + textAlign: TextAlign.center, + ).paddedSymmetrically(horizontal: 16), + const Icon( + Icons.fingerprint, + size: 96, + ), + // Wrap( + // alignment: WrapAlignment.spaceBetween, + // runAlignment: WrapAlignment.spaceBetween, + // runSpacing: 8, + // spacing: 8, + // children: [ + + // ], + // ).padded(16), + ], + ), + ), + ); + } +} 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 3936de8..b0c3753 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 @@ -1,4 +1,5 @@ import 'dart:io'; +import 'dart:typed_data'; import 'package:file_picker/file_picker.dart'; import 'package:flutter/material.dart'; @@ -12,11 +13,16 @@ import 'obscured_input_text_form_field.dart'; class ClientCertificateFormField extends StatefulWidget { static const fkClientCertificate = 'clientCertificate'; + final String? initialPassphrase; + final Uint8List? initialBytes; + final void Function(ClientCertificateFormModel? cert) onChanged; const ClientCertificateFormField({ - Key? key, + super.key, required this.onChanged, - }) : super(key: key); + this.initialPassphrase, + this.initialBytes, + }); @override State createState() => @@ -31,7 +37,12 @@ class _ClientCertificateFormFieldState return FormBuilderField( key: const ValueKey('login-client-cert'), onChanged: widget.onChanged, - initialValue: null, + initialValue: widget.initialBytes != null + ? ClientCertificateFormModel( + bytes: widget.initialBytes!, + passphrase: widget.initialPassphrase, + ) + : null, validator: (value) { if (value == null) { return null; @@ -108,8 +119,7 @@ class _ClientCertificateFormFieldState ), label: S.of(context)!.passphrase, ).padded(), - ] else - ...[] + ] ], ), ), @@ -122,20 +132,23 @@ class _ClientCertificateFormFieldState } Future _onSelectFile( - FormFieldState field) async { - FilePickerResult? result = await FilePicker.platform.pickFiles( + FormFieldState field, + ) async { + final result = await FilePicker.platform.pickFiles( allowMultiple: false, ); - if (result != null && result.files.single.path != null) { - File file = File(result.files.single.path!); - setState(() { - _selectedFile = file; - }); - final changedValue = - field.value?.copyWith(bytes: file.readAsBytesSync()) ?? - ClientCertificateFormModel(bytes: file.readAsBytesSync()); - field.didChange(changedValue); + if (result == null || result.files.single.path == null) { + return; } + File file = File(result.files.single.path!); + setState(() { + _selectedFile = file; + }); + final bytes = await file.readAsBytes(); + + final changedValue = field.value?.copyWith(bytes: bytes) ?? + ClientCertificateFormModel(bytes: bytes); + field.didChange(changedValue); } Widget _buildSelectedFileText( diff --git a/lib/features/login/view/widgets/form_fields/server_address_form_field.dart b/lib/features/login/view/widgets/form_fields/server_address_form_field.dart index fe09006..cff93d8 100644 --- a/lib/features/login/view/widgets/form_fields/server_address_form_field.dart +++ b/lib/features/login/view/widgets/form_fields/server_address_form_field.dart @@ -8,11 +8,12 @@ import 'package:paperless_mobile/generated/l10n/app_localizations.dart'; class ServerAddressFormField extends StatefulWidget { static const String fkServerAddress = "serverAddress"; - + final String? initialValue; final void Function(String? address) onSubmit; const ServerAddressFormField({ Key? key, required this.onSubmit, + this.initialValue, }) : super(key: key); @override @@ -38,6 +39,7 @@ class _ServerAddressFormFieldState extends State { @override Widget build(BuildContext context) { return FormBuilderField( + initialValue: widget.initialValue, name: ServerAddressFormField.fkServerAddress, autovalidateMode: AutovalidateMode.onUserInteraction, validator: (value) { @@ -90,7 +92,7 @@ class _ServerAddressFormFieldState extends State { ) : null, ), - autofocus: true, + autofocus: false, onSubmitted: (_) { onFieldSubmitted(); _formatInput(); 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 deb24c1..397d563 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 @@ -1,19 +1,27 @@ import 'package:flutter/material.dart'; import 'package:flutter_form_builder/flutter_form_builder.dart'; +import 'package:hive_flutter/adapters.dart'; +import 'package:paperless_mobile/core/config/hive/hive_extensions.dart'; import 'package:paperless_mobile/extensions/flutter_extensions.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/features/login/view/widgets/form_fields/server_address_form_field.dart'; import 'package:paperless_mobile/generated/l10n/app_localizations.dart'; class UserCredentialsFormField extends StatefulWidget { static const fkCredentials = 'credentials'; final void Function() onFieldsSubmitted; - + final String? initialUsername; + final String? initialPassword; + final GlobalKey formKey; const UserCredentialsFormField({ Key? key, required this.onFieldsSubmitted, + this.initialUsername, + this.initialPassword, + required this.formKey, }) : super(key: key); @override @@ -28,6 +36,10 @@ class _UserCredentialsFormFieldState extends State { @override Widget build(BuildContext context) { return FormBuilderField( + initialValue: LoginFormCredentials( + password: widget.initialPassword, + username: widget.initialUsername, + ), name: UserCredentialsFormField.fkCredentials, builder: (field) => AutofillGroup( child: Column( @@ -50,6 +62,17 @@ class _UserCredentialsFormFieldState extends State { if (value?.trim().isEmpty ?? true) { return S.of(context)!.usernameMustNotBeEmpty; } + final serverAddress = widget.formKey.currentState! + .getRawValue( + ServerAddressFormField.fkServerAddress); + if (serverAddress != null) { + final userExists = Hive.localUserAccountBox.values + .map((e) => e.id) + .contains('$value@$serverAddress'); + if (userExists) { + return S.of(context)!.userAlreadyExists; + } + } return null; }, autofillHints: const [AutofillHints.username], 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 deleted file mode 100644 index 29a3c96..0000000 --- a/lib/features/login/view/widgets/login_pages/server_connection_page.dart +++ /dev/null @@ -1,170 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:flutter_form_builder/flutter_form_builder.dart'; -import 'package:paperless_mobile/core/service/connectivity_status_service.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'; -import 'package:paperless_mobile/generated/l10n/app_localizations.dart'; - -import 'package:provider/provider.dart'; - -class ServerConnectionPage extends StatefulWidget { - final GlobalKey formBuilderKey; - final VoidCallback onContinue; - final String titleText; - - const ServerConnectionPage({ - super.key, - required this.formBuilderKey, - required this.onContinue, - required this.titleText, - }); - - @override - State createState() => _ServerConnectionPageState(); -} - -class _ServerConnectionPageState extends State { - bool _isCheckingConnection = false; - ReachabilityStatus _reachabilityStatus = ReachabilityStatus.unknown; - - @override - Widget build(BuildContext context) { - return Scaffold( - appBar: AppBar( - toolbarHeight: kToolbarHeight - 4, - title: Text(widget.titleText), - bottom: PreferredSize( - child: _isCheckingConnection - ? const LinearProgressIndicator() - : const SizedBox(height: 4.0), - preferredSize: const Size.fromHeight(4.0), - ), - ), - resizeToAvoidBottomInset: true, - body: SingleChildScrollView( - child: Column( - children: [ - ServerAddressFormField( - onSubmit: (address) { - _updateReachability(address); - }, - ).padded(), - ClientCertificateFormField( - onChanged: (_) => _updateReachability(), - ).padded(), - _buildStatusIndicator(), - ], - ).padded(), - ), - bottomNavigationBar: BottomAppBar( - child: Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, - children: [ - TextButton( - child: Text(S.of(context)!.testConnection), - onPressed: _updateReachability, - ), - FilledButton( - child: Text(S.of(context)!.continueLabel), - onPressed: _reachabilityStatus == ReachabilityStatus.reachable - ? widget.onContinue - : null, - ), - ], - ), - ), - ); - } - - Future _updateReachability([String? address]) async { - setState(() { - _isCheckingConnection = true; - }); - final certForm = widget.formBuilderKey.currentState - ?.getRawValue(ClientCertificateFormField.fkClientCertificate) - as ClientCertificateFormModel?; - final status = await context - .read() - .isPaperlessServerReachable( - address ?? - widget.formBuilderKey.currentState! - .getRawValue(ServerAddressFormField.fkServerAddress), - certForm != null - ? ClientCertificate( - bytes: certForm.bytes, passphrase: certForm.passphrase) - : null, - ); - setState(() { - _isCheckingConnection = false; - _reachabilityStatus = status; - }); - } - - Widget _buildStatusIndicator() { - if (_isCheckingConnection) { - return const ListTile(); - } - Color errorColor = Theme.of(context).colorScheme.error; - switch (_reachabilityStatus) { - case ReachabilityStatus.unknown: - return Container(); - case ReachabilityStatus.reachable: - return _buildIconText( - Icons.done, - S.of(context)!.connectionSuccessfulylEstablished, - Colors.green, - ); - case ReachabilityStatus.notReachable: - return _buildIconText( - Icons.close, - S.of(context)!.couldNotEstablishConnectionToTheServer, - errorColor, - ); - case ReachabilityStatus.unknownHost: - return _buildIconText( - Icons.close, - S.of(context)!.hostCouldNotBeResolved, - errorColor, - ); - case ReachabilityStatus.missingClientCertificate: - return _buildIconText( - Icons.close, - S.of(context)!.loginPageReachabilityMissingClientCertificateText, - errorColor, - ); - case ReachabilityStatus.invalidClientCertificateConfiguration: - return _buildIconText( - Icons.close, - S.of(context)!.incorrectOrMissingCertificatePassphrase, - errorColor, - ); - case ReachabilityStatus.connectionTimeout: - return _buildIconText( - Icons.close, - S.of(context)!.connectionTimedOut, - errorColor, - ); - } - } - - Widget _buildIconText( - IconData icon, - String text, [ - Color? color, - ]) { - return ListTile( - title: Text( - text, - style: Theme.of(context).textTheme.bodySmall?.copyWith(color: color), - ), - leading: Icon( - icon, - color: color, - ), - ); - } -} 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 deleted file mode 100644 index 1951aa3..0000000 --- a/lib/features/login/view/widgets/login_pages/server_login_page.dart +++ /dev/null @@ -1,85 +0,0 @@ -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/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/generated/l10n/app_localizations.dart'; - -class ServerLoginPage extends StatefulWidget { - final String submitText; - final Future Function() onSubmit; - final GlobalKey formBuilderKey; - - const ServerLoginPage({ - super.key, - required this.onSubmit, - required this.formBuilderKey, - required this.submitText, - }); - - @override - State createState() => _ServerLoginPageState(); -} - -class _ServerLoginPageState extends State { - bool _isLoginLoading = false; - @override - Widget build(BuildContext context) { - final serverAddress = (widget.formBuilderKey.currentState - ?.getRawValue(ServerAddressFormField.fkServerAddress) - as String?) - ?.replaceAll(RegExp(r'https?://'), '') ?? - ''; - return Scaffold( - appBar: AppBar( - title: Text(S.of(context)!.loginPageSignInTitle), - bottom: _isLoginLoading - ? const PreferredSize( - preferredSize: Size.fromHeight(4.0), - child: LinearProgressIndicator(), - ) - : null, - ), - body: ListView( - children: [ - Text( - S.of(context)!.signInToServer(serverAddress) + ":", - style: Theme.of(context).textTheme.labelLarge, - ).padded(16), - UserCredentialsFormField( - onFieldsSubmitted: widget.onSubmit, - ), - Text( - S.of(context)!.loginRequiredPermissionsHint, - style: Theme.of(context).textTheme.bodySmall?.apply( - color: Theme.of(context) - .colorScheme - .onBackground - .withOpacity(0.6), - ), - ).padded(16), - ], - ), - bottomNavigationBar: BottomAppBar( - child: Row( - mainAxisAlignment: MainAxisAlignment.end, - children: [ - FilledButton( - onPressed: !_isLoginLoading - ? () async { - setState(() => _isLoginLoading = true); - try { - await widget.onSubmit(); - } finally { - setState(() => _isLoginLoading = false); - } - } - : null, - child: Text(S.of(context)!.signIn), - ) - ], - ), - ), - ); - } -} diff --git a/lib/features/login/view/widgets/login_transition_page.dart b/lib/features/login/view/widgets/login_transition_page.dart new file mode 100644 index 0000000..3976418 --- /dev/null +++ b/lib/features/login/view/widgets/login_transition_page.dart @@ -0,0 +1,34 @@ +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:paperless_mobile/extensions/flutter_extensions.dart'; +import 'package:paperless_mobile/theme.dart'; + +class LoginTransitionPage extends StatelessWidget { + final String text; + const LoginTransitionPage({super.key, required this.text}); + + @override + Widget build(BuildContext context) { + return WillPopScope( + onWillPop: () async => false, + child: AnnotatedRegion( + value: buildOverlayStyle( + Theme.of(context), + systemNavigationBarColor: Theme.of(context).colorScheme.background, + ), + child: Scaffold( + body: Stack( + alignment: Alignment.center, + children: [ + const CircularProgressIndicator(), + Align( + alignment: Alignment.bottomCenter, + child: Text(text).paddedOnly(bottom: 24), + ), + ], + ).padded(16), + ), + ), + ); + } +} diff --git a/lib/features/login/view/widgets/never_scrollable_scroll_behavior.dart b/lib/features/login/view/widgets/never_scrollable_scroll_behavior.dart deleted file mode 100644 index bfdc0fd..0000000 --- a/lib/features/login/view/widgets/never_scrollable_scroll_behavior.dart +++ /dev/null @@ -1,8 +0,0 @@ -import 'package:flutter/widgets.dart'; - -class NeverScrollableScrollBehavior extends ScrollBehavior { - @override - ScrollPhysics getScrollPhysics(BuildContext context) { - return const NeverScrollableScrollPhysics(); - } -} diff --git a/lib/features/notifications/services/local_notification_service.dart b/lib/features/notifications/services/local_notification_service.dart index 01c8246..bfad7fc 100644 --- a/lib/features/notifications/services/local_notification_service.dart +++ b/lib/features/notifications/services/local_notification_service.dart @@ -54,6 +54,7 @@ class LocalNotificationService { required bool finished, required String locale, required String userId, + double? progress, }) async { final tr = await S.delegate.load(Locale(locale)); @@ -68,8 +69,10 @@ class LocalNotificationService { android: AndroidNotificationDetails( NotificationChannel.documentDownload.id + "_${document.id}", NotificationChannel.documentDownload.name, + progress: ((progress ?? 0) * 100).toInt(), + maxProgress: 100, + indeterminate: progress == null && !finished, ongoing: !finished, - indeterminate: true, importance: Importance.max, priority: Priority.high, showProgress: !finished, diff --git a/lib/features/paged_document_view/cubit/document_paging_bloc_mixin.dart b/lib/features/paged_document_view/cubit/document_paging_bloc_mixin.dart index 1c68313..3c9cd67 100644 --- a/lib/features/paged_document_view/cubit/document_paging_bloc_mixin.dart +++ b/lib/features/paged_document_view/cubit/document_paging_bloc_mixin.dart @@ -3,7 +3,6 @@ import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:paperless_api/paperless_api.dart'; import 'package:paperless_mobile/core/notifier/document_changed_notifier.dart'; import 'package:paperless_mobile/core/service/connectivity_status_service.dart'; -import 'package:rxdart/streams.dart'; import 'paged_documents_state.dart'; @@ -51,6 +50,7 @@ mixin DocumentPagingBlocMixin /// Use [loadMore] to load more data. Future updateFilter({ final DocumentFilter filter = const DocumentFilter(), + bool emitLoading = true, }) async { final hasConnection = await connectivityStatusService.isConnectedToInternet(); @@ -60,7 +60,9 @@ mixin DocumentPagingBlocMixin .expand((page) => page.results) .where((doc) => filter.matches(doc)) .toList(); - emit(state.copyWithPaged(isLoading: true)); + if (emitLoading) { + emit(state.copyWithPaged(isLoading: true)); + } emit( state.copyWithPaged( @@ -79,7 +81,9 @@ mixin DocumentPagingBlocMixin return; } try { - emit(state.copyWithPaged(isLoading: true)); + if (emitLoading) { + emit(state.copyWithPaged(isLoading: true)); + } final result = await api.findAll(filter.copyWith(page: 1)); emit( @@ -146,7 +150,7 @@ mixin DocumentPagingBlocMixin /// Deletes a document and removes it from the currently loaded state. /// Future delete(DocumentModel document) async { - emit(state.copyWithPaged(isLoading: true)); + // emit(state.copyWithPaged(isLoading: true)); try { await api.delete(document); notifier.notifyDeleted(document); @@ -213,6 +217,7 @@ mixin DocumentPagingBlocMixin } } + @override Future close() { notifier.removeListener(this); diff --git a/lib/features/saved_view/view/saved_view_list.dart b/lib/features/saved_view/view/saved_view_list.dart deleted file mode 100644 index bc6b935..0000000 --- a/lib/features/saved_view/view/saved_view_list.dart +++ /dev/null @@ -1,62 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:flutter_bloc/flutter_bloc.dart'; -import 'package:paperless_mobile/core/bloc/connectivity_cubit.dart'; -import 'package:paperless_mobile/core/navigation/push_routes.dart'; -import 'package:paperless_mobile/core/widgets/hint_card.dart'; -import 'package:paperless_mobile/features/saved_view/cubit/saved_view_cubit.dart'; -import 'package:paperless_mobile/features/saved_view/view/saved_view_loading_sliver_list.dart'; -import 'package:paperless_mobile/generated/l10n/app_localizations.dart'; - -class SavedViewList extends StatelessWidget { - const SavedViewList({super.key}); - - @override - Widget build(BuildContext context) { - return BlocBuilder( - builder: (context, connectivity) { - return BlocBuilder( - builder: (context, state) { - return state.when( - initial: () => const SavedViewLoadingSliverList(), - loading: () => const SavedViewLoadingSliverList(), - loaded: (savedViews) { - if (savedViews.isEmpty) { - return SliverToBoxAdapter( - child: HintCard( - hintText: S - .of(context)! - .createViewsToQuicklyFilterYourDocuments, - ), - ); - } - return SliverList.builder( - itemBuilder: (context, index) { - final view = savedViews.values.elementAt(index); - return ListTile( - enabled: connectivity.isConnected, - title: Text(view.name), - subtitle: Text( - S.of(context)!.nFiltersSet(view.filterRules.length), - ), - onTap: () { - pushSavedViewDetailsRoute(context, savedView: view); - }, - ); - }, - itemCount: savedViews.length, - ); - }, - error: () => const SliverToBoxAdapter( - child: Center( - child: Text( - "An error occurred while trying to load the saved views.", - ), - ), - ), - ); - }, - ); - }, - ); - } -} diff --git a/lib/features/saved_view/view/saved_view_loading_sliver_list.dart b/lib/features/saved_view/view/saved_view_loading_sliver_list.dart deleted file mode 100644 index 2468115..0000000 --- a/lib/features/saved_view/view/saved_view_loading_sliver_list.dart +++ /dev/null @@ -1,32 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:paperless_mobile/core/widgets/shimmer_placeholder.dart'; - -class SavedViewLoadingSliverList extends StatelessWidget { - const SavedViewLoadingSliverList({super.key}); - - @override - Widget build(BuildContext context) { - return SliverList.builder( - itemBuilder: (context, index) => ShimmerPlaceholder( - child: ListTile( - title: Align( - alignment: Alignment.centerLeft, - child: Container( - width: 300, - height: 14, - color: Colors.white, - ), - ), - subtitle: Align( - alignment: Alignment.centerLeft, - child: Container( - width: 150, - height: 12, - color: Colors.white, - ), - ), - ), - ), - ); - } -} diff --git a/lib/features/saved_view_details/view/saved_view_details_page.dart b/lib/features/saved_view_details/view/saved_view_details_page.dart deleted file mode 100644 index a98634b..0000000 --- a/lib/features/saved_view_details/view/saved_view_details_page.dart +++ /dev/null @@ -1,102 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:flutter_bloc/flutter_bloc.dart'; -import 'package:go_router/go_router.dart'; -import 'package:paperless_api/paperless_api.dart'; -import 'package:paperless_mobile/core/bloc/connectivity_cubit.dart'; -import 'package:paperless_mobile/core/navigation/push_routes.dart'; -import 'package:paperless_mobile/features/documents/view/widgets/adaptive_documents_view.dart'; -import 'package:paperless_mobile/features/documents/view/widgets/documents_empty_state.dart'; -import 'package:paperless_mobile/features/documents/view/widgets/selection/confirm_delete_saved_view_dialog.dart'; -import 'package:paperless_mobile/features/documents/view/widgets/selection/view_type_selection_widget.dart'; -import 'package:paperless_mobile/features/paged_document_view/view/document_paging_view_mixin.dart'; -import 'package:paperless_mobile/features/saved_view_details/cubit/saved_view_details_cubit.dart'; -import 'package:paperless_mobile/routes/typed/branches/documents_route.dart'; - -class SavedViewDetailsPage extends StatefulWidget { - final Future Function(SavedView savedView) onDelete; - const SavedViewDetailsPage({ - super.key, - required this.onDelete, - }); - - @override - State createState() => _SavedViewDetailsPageState(); -} - -class _SavedViewDetailsPageState extends State - with DocumentPagingViewMixin { - @override - final pagingScrollController = ScrollController(); - - @override - Widget build(BuildContext context) { - final cubit = context.watch(); - return Scaffold( - appBar: AppBar( - title: Text(cubit.savedView.name), - actions: [ - IconButton( - icon: const Icon(Icons.delete), - onPressed: () async { - final shouldDelete = await showDialog( - context: context, - builder: (context) => ConfirmDeleteSavedViewDialog( - view: cubit.savedView, - ), - ) ?? - false; - if (shouldDelete) { - await widget.onDelete(cubit.savedView); - context.pop(context); - } - }, - ), - BlocBuilder( - builder: (context, state) { - return ViewTypeSelectionWidget( - viewType: state.viewType, - onChanged: cubit.setViewType, - ); - }, - ), - ], - ), - body: BlocBuilder( - builder: (context, state) { - if (state.hasLoaded && state.documents.isEmpty) { - return DocumentsEmptyState(state: state); - } - return BlocBuilder( - builder: (context, connectivity) { - return CustomScrollView( - controller: pagingScrollController, - slivers: [ - SliverAdaptiveDocumentsView( - documents: state.documents, - hasInternetConnection: connectivity.isConnected, - isLabelClickable: false, - isLoading: state.isLoading, - hasLoaded: state.hasLoaded, - onTap: (document) { - DocumentDetailsRoute( - $extra: document, - isLabelClickable: false, - ).push(context); - }, - viewType: state.viewType, - ), - if (state.hasLoaded && state.isLoading) - const SliverToBoxAdapter( - child: Center( - child: CircularProgressIndicator(), - ), - ) - ], - ); - }, - ); - }, - ), - ); - } -} diff --git a/lib/features/saved_view_details/view/saved_view_preview.dart b/lib/features/saved_view_details/view/saved_view_preview.dart index ad1ff93..2ebb459 100644 --- a/lib/features/saved_view_details/view/saved_view_preview.dart +++ b/lib/features/saved_view_details/view/saved_view_preview.dart @@ -1,5 +1,3 @@ -import 'dart:ui'; - import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:paperless_api/paperless_api.dart'; diff --git a/lib/features/settings/view/manage_accounts_page.dart b/lib/features/settings/view/manage_accounts_page.dart index 5cce035..1669716 100644 --- a/lib/features/settings/view/manage_accounts_page.dart +++ b/lib/features/settings/view/manage_accounts_page.dart @@ -1,16 +1,13 @@ import 'package:collection/collection.dart'; 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/core/database/tables/local_user_account.dart'; -import 'package:paperless_mobile/features/home/view/model/api_version.dart'; +import 'package:paperless_mobile/core/config/hive/hive_extensions.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/view/add_account_page.dart'; import 'package:paperless_mobile/features/settings/view/dialogs/switch_account_dialog.dart'; import 'package:paperless_mobile/features/settings/view/widgets/global_settings_builder.dart'; import 'package:paperless_mobile/features/users/view/widgets/user_account_list_tile.dart'; import 'package:paperless_mobile/generated/l10n/app_localizations.dart'; +import 'package:paperless_mobile/routes/typed/top_level/add_account_route.dart'; import 'package:provider/provider.dart'; class ManageAccountsPage extends StatelessWidget { @@ -20,16 +17,15 @@ class ManageAccountsPage extends StatelessWidget { Widget build(BuildContext context) { return GlobalSettingsBuilder( builder: (context, globalSettings) { - // This is one of the few places where the currentLoggedInUser can be null - // (exactly after loggin out as the current user to be precise). - if (globalSettings.loggedInUserId == null) { - return const SizedBox.shrink(); - } + // // This is one of the few places where the currentLoggedInUser can be null + // // (exactly after loggin out as the current user to be precise). + return ValueListenableBuilder( - valueListenable: - Hive.box(HiveBoxes.localUserAccount) - .listenable(), + valueListenable: Hive.localUserAccountBox.listenable(), builder: (context, box, _) { + if (globalSettings.loggedInUserId == null) { + return const SizedBox.shrink(); + } final userIds = box.keys.toList().cast(); final otherAccounts = userIds .whereNot((element) => element == globalSettings.loggedInUserId) @@ -70,6 +66,7 @@ class ManageAccountsPage extends StatelessWidget { ], onSelected: (value) async { if (value == 0) { + Navigator.of(context).pop(); await context .read() .logout(true); @@ -133,11 +130,12 @@ class ManageAccountsPage extends StatelessWidget { _onAddAccount(context, globalSettings.loggedInUserId!); }, ), - if (context.watch().hasMultiUserSupport) - ListTile( - leading: const Icon(Icons.admin_panel_settings), - title: Text(S.of(context)!.managePermissions), - ), + //TODO: Implement permission/user settings at some point... + // if (context.watch().hasMultiUserSupport) + // ListTile( + // leading: const Icon(Icons.admin_panel_settings), + // title: Text(S.of(context)!.managePermissions), + // ), ], ); }, @@ -147,43 +145,43 @@ class ManageAccountsPage extends StatelessWidget { } Future _onAddAccount(BuildContext context, String currentUser) async { - final userId = await Navigator.push( - context, - MaterialPageRoute( - builder: (context) => AddAccountPage( - titleString: S.of(context)!.addAccount, - 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, - ); - Navigator.of(context).pop(userId); - }, - submitText: S.of(context)!.addAccount, - ), - ), - ); - if (userId != null) { - final shoudSwitch = await showDialog( - context: context, - builder: (context) => const SwitchAccountDialog(), - ) ?? - false; - if (shoudSwitch) { - _onSwitchAccount(context, currentUser, userId); - } - } + Navigator.of(context).pop(); + AddAccountRoute().push(context); + // final userId = await Navigator.push( + // context, + // MaterialPageRoute( + // builder: (context) => AddAccountPage( + // titleText: S.of(context)!.addAccount, + // onSubmit: (context, username, password, serverUrl, + // clientCertificate) async { + // try { + // 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, + // ); + + // Navigator.of(context).pop(userId); + // } on PaperlessFormValidationException catch (error) {} + // }, + // submitText: S.of(context)!.addAccount, + // ), + // ), + // ); + } void _onSwitchAccount( - BuildContext context, String currentUser, String newUser) async { + BuildContext context, + String currentUser, + String newUser, + ) async { if (currentUser == newUser) return; Navigator.of(context).pop(); diff --git a/lib/features/settings/view/pages/switching_accounts_page.dart b/lib/features/settings/view/pages/switching_accounts_page.dart deleted file mode 100644 index 8bfd21d..0000000 --- a/lib/features/settings/view/pages/switching_accounts_page.dart +++ /dev/null @@ -1,29 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:paperless_mobile/generated/l10n/app_localizations.dart'; - -class SwitchingAccountsPage extends StatelessWidget { - const SwitchingAccountsPage({super.key}); - - @override - Widget build(BuildContext context) { - return WillPopScope( - onWillPop: () async => false, - child: Material( - child: Center( - child: Column( - crossAxisAlignment: CrossAxisAlignment.center, - mainAxisAlignment: MainAxisAlignment.center, - children: [ - const CircularProgressIndicator(), - const SizedBox(height: 16), - Text( - S.of(context)!.switchingAccountsPleaseWait, - style: Theme.of(context).textTheme.labelLarge, - ), - ], - ), - ), - ), - ); - } -} diff --git a/lib/features/settings/view/widgets/language_selection_setting.dart b/lib/features/settings/view/widgets/language_selection_setting.dart index c537abd..8787acb 100644 --- a/lib/features/settings/view/widgets/language_selection_setting.dart +++ b/lib/features/settings/view/widgets/language_selection_setting.dart @@ -21,6 +21,7 @@ class _LanguageSelectionSettingState extends State { 'tr': LanguageOption('Türkçe', true), 'pl': LanguageOption('Polska', true), 'ca': LanguageOption('Catalan', true), + 'ru': LanguageOption('Русский', true), }; @override @@ -34,9 +35,9 @@ class _LanguageSelectionSettingState extends State { onTap: () => showDialog( context: context, builder: (_) => RadioSettingsDialog( - footer: const Text( - "* Not fully translated yet. Some words may be displayed in English!", - ), + // footer: const Text( + // "* Not fully translated yet. Some words may be displayed in English!", + // ), titleText: S.of(context)!.language, options: [ for (var language in _languageOptions.entries) diff --git a/lib/features/settings/view/widgets/skip_document_prepraration_on_share_setting.dart b/lib/features/settings/view/widgets/skip_document_prepraration_on_share_setting.dart index 0d035c6..473e488 100644 --- a/lib/features/settings/view/widgets/skip_document_prepraration_on_share_setting.dart +++ b/lib/features/settings/view/widgets/skip_document_prepraration_on_share_setting.dart @@ -1,5 +1,6 @@ import 'package:flutter/material.dart'; import 'package:paperless_mobile/features/settings/view/widgets/global_settings_builder.dart'; +import 'package:paperless_mobile/generated/l10n/app_localizations.dart'; class SkipDocumentPreprationOnShareSetting extends StatelessWidget { const SkipDocumentPreprationOnShareSetting({super.key}); @@ -9,9 +10,8 @@ class SkipDocumentPreprationOnShareSetting extends StatelessWidget { return GlobalSettingsBuilder( builder: (context, settings) { return SwitchListTile( - title: Text("Direct share"), - subtitle: - Text("Always directly upload when sharing files with the app."), + title: Text(S.of(context)!.skipEditingReceivedFiles), + subtitle: Text(S.of(context)!.uploadWithoutPromptingUploadForm), value: settings.skipDocumentPreprarationOnUpload, onChanged: (value) { settings.skipDocumentPreprarationOnUpload = value; diff --git a/lib/features/sharing/logic/upload_queue_processor.dart b/lib/features/sharing/logic/upload_queue_processor.dart deleted file mode 100644 index 89b0638..0000000 --- a/lib/features/sharing/logic/upload_queue_processor.dart +++ /dev/null @@ -1,73 +0,0 @@ -import 'dart:io'; - -import 'package:collection/collection.dart'; -import 'package:flutter/material.dart'; -import 'package:flutter/services.dart'; -import 'package:flutter/widgets.dart'; -import 'package:flutter_bloc/flutter_bloc.dart'; -import 'package:fluttertoast/fluttertoast.dart'; -import 'package:paperless_api/paperless_api.dart'; -import 'package:paperless_mobile/core/database/tables/local_user_account.dart'; -import 'package:paperless_mobile/core/global/constants.dart'; -import 'package:paperless_mobile/core/translation/error_code_localization_mapper.dart'; -import 'package:paperless_mobile/features/document_upload/view/document_upload_preparation_page.dart'; -import 'package:paperless_mobile/features/sharing/model/share_intent_queue.dart'; -import 'package:paperless_mobile/features/sharing/view/dialog/discard_shared_file_dialog.dart'; -import 'package:paperless_mobile/generated/l10n/app_localizations.dart'; -import 'package:paperless_mobile/routes/typed/branches/scanner_route.dart'; -import 'package:receive_sharing_intent/receive_sharing_intent.dart'; -import 'package:path/path.dart' as p; - -class UploadQueueProcessor { - final ShareIntentQueue queue; - - UploadQueueProcessor({required this.queue}); - - bool _isFileTypeSupported(File file) { - final isSupported = - supportedFileExtensions.contains(p.extension(file.path)); - return isSupported; - } - - void processIncomingFiles( - BuildContext context, { - required List sharedFiles, - }) async { - if (sharedFiles.isEmpty) { - return; - } - Iterable files = sharedFiles.map((file) => File(file.path)); - if (Platform.isIOS) { - files = files - .map((file) => File(file.path.replaceAll('file://', ''))) - .toList(); - } - final supportedFiles = files.where(_isFileTypeSupported); - final unsupportedFiles = files.whereNot(_isFileTypeSupported); - debugPrint( - "Received ${files.length} files, out of which ${supportedFiles.length} are supported.}"); - if (supportedFiles.isEmpty) { - Fluttertoast.showToast( - msg: translateError( - context, - ErrorCode.unsupportedFileFormat, - ), - ); - if (Platform.isAndroid) { - // As stated in the docs, SystemNavigator.pop() is ignored on IOS to comply with HCI guidelines. - await SystemNavigator.pop(); - } - return; - } - if (unsupportedFiles.isNotEmpty) { - //TODO: INTL - Fluttertoast.showToast( - msg: - "${unsupportedFiles.length}/${files.length} files could not be processed."); - } - await ShareIntentQueue.instance.addAll( - supportedFiles, - userId: context.read().id, - ); - } -} diff --git a/lib/features/sharing/model/share_intent_queue.dart b/lib/features/sharing/model/share_intent_queue.dart deleted file mode 100644 index 6bd29a2..0000000 --- a/lib/features/sharing/model/share_intent_queue.dart +++ /dev/null @@ -1,105 +0,0 @@ -import 'dart:collection'; -import 'dart:io'; - -import 'package:flutter/widgets.dart'; -import 'package:hive/hive.dart'; -import 'package:paperless_mobile/core/config/hive/hive_extensions.dart'; -import 'package:paperless_mobile/core/service/file_service.dart'; -import 'package:receive_sharing_intent/receive_sharing_intent.dart'; -import 'package:path/path.dart' as p; - -class ShareIntentQueue extends ChangeNotifier { - final Map> _queues = {}; - - ShareIntentQueue._(); - - static final instance = ShareIntentQueue._(); - - Future initialize() async { - final users = Hive.localUserAccountBox.values; - for (final user in users) { - final userId = user.id; - debugPrint("Locating remaining files to be uploaded for $userId..."); - final consumptionDir = - await FileService.getConsumptionDirectory(userId: userId); - final files = await FileService.getAllFiles(consumptionDir); - debugPrint( - "Found ${files.length} files to be uploaded for $userId. Adding to queue..."); - getQueue(userId).addAll(files); - } - } - - void add( - File file, { - required String userId, - }) => - addAll([file], userId: userId); - - Future addAll( - Iterable files, { - required String userId, - }) async { - if (files.isEmpty) { - return; - } - final consumptionDirectory = - await FileService.getConsumptionDirectory(userId: userId); - final copiedFiles = await Future.wait([ - for (var file in files) - file.copy('${consumptionDirectory.path}/${p.basename(file.path)}') - ]); - - debugPrint( - "Adding received files to queue: ${files.map((e) => e.path).join(",")}", - ); - getQueue(userId).addAll(copiedFiles); - notifyListeners(); - } - - /// Removes and returns the first item in the requested user's queue if it exists. - File? pop(String userId) { - if (hasUnhandledFiles(userId: userId)) { - final file = getQueue(userId).removeFirst(); - notifyListeners(); - return file; - // Don't notify listeners, only when new item is added. - } - return null; - } - - Future onConsumed(File file) { - debugPrint( - "File ${file.path} successfully consumed. Delelting local copy."); - return file.delete(); - } - - Future discard(File file) { - debugPrint("Discarding file ${file.path}."); - return file.delete(); - } - - /// Returns whether the queue of the requested user contains files waiting for processing. - bool hasUnhandledFiles({ - required String userId, - }) => - getQueue(userId).isNotEmpty; - - int unhandledFileCount({ - required String userId, - }) => - getQueue(userId).length; - - Queue getQueue(String userId) { - if (!_queues.containsKey(userId)) { - _queues[userId] = Queue(); - } - return _queues[userId]!; - } -} - -class UserAwareShareMediaFile { - final String userId; - final SharedMediaFile sharedFile; - - UserAwareShareMediaFile(this.userId, this.sharedFile); -} diff --git a/lib/features/sharing/view/consumption_queue_view.dart b/lib/features/sharing/view/consumption_queue_view.dart index e2d2617..7b437b4 100644 --- a/lib/features/sharing/view/consumption_queue_view.dart +++ b/lib/features/sharing/view/consumption_queue_view.dart @@ -1,9 +1,8 @@ import 'package:flutter/material.dart'; import 'package:paperless_mobile/core/database/tables/local_user_account.dart'; -import 'package:paperless_mobile/extensions/flutter_extensions.dart'; import 'package:paperless_mobile/features/sharing/cubit/receive_share_cubit.dart'; import 'package:paperless_mobile/features/sharing/view/widgets/file_thumbnail.dart'; -import 'package:paperless_mobile/features/sharing/view/widgets/upload_queue_shell.dart'; +import 'package:paperless_mobile/features/sharing/view/widgets/event_listener_shell.dart'; import 'package:paperless_mobile/generated/l10n/app_localizations.dart'; import 'package:path/path.dart' as p; import 'package:provider/provider.dart'; diff --git a/lib/features/sharing/view/widgets/upload_queue_shell.dart b/lib/features/sharing/view/widgets/event_listener_shell.dart similarity index 73% rename from lib/features/sharing/view/widgets/upload_queue_shell.dart rename to lib/features/sharing/view/widgets/event_listener_shell.dart index 59d8f14..33a4426 100644 --- a/lib/features/sharing/view/widgets/upload_queue_shell.dart +++ b/lib/features/sharing/view/widgets/event_listener_shell.dart @@ -3,19 +3,22 @@ import 'dart:io'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; +import 'package:flutter_animate/flutter_animate.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:fluttertoast/fluttertoast.dart'; import 'package:hive/hive.dart'; import 'package:paperless_api/paperless_api.dart'; +import 'package:paperless_mobile/core/bloc/connectivity_cubit.dart'; import 'package:paperless_mobile/core/config/hive/hive_config.dart'; import 'package:paperless_mobile/core/config/hive/hive_extensions.dart'; import 'package:paperless_mobile/core/database/tables/local_user_account.dart'; +import 'package:paperless_mobile/core/notifier/document_changed_notifier.dart'; import 'package:paperless_mobile/core/service/connectivity_status_service.dart'; import 'package:paperless_mobile/features/document_upload/view/document_upload_preparation_page.dart'; +import 'package:paperless_mobile/features/inbox/cubit/inbox_cubit.dart'; import 'package:paperless_mobile/features/notifications/services/local_notification_service.dart'; import 'package:paperless_mobile/features/sharing/cubit/receive_share_cubit.dart'; import 'package:paperless_mobile/features/sharing/view/dialog/discard_shared_file_dialog.dart'; -import 'package:paperless_mobile/features/sharing/view/dialog/pending_files_info_dialog.dart'; import 'package:paperless_mobile/features/tasks/model/pending_tasks_notifier.dart'; import 'package:paperless_mobile/generated/l10n/app_localizations.dart'; import 'package:paperless_mobile/helpers/message_helpers.dart'; @@ -23,25 +26,33 @@ import 'package:paperless_mobile/routes/typed/branches/scanner_route.dart'; import 'package:path/path.dart' as p; import 'package:receive_sharing_intent/receive_sharing_intent.dart'; -class UploadQueueShell extends StatefulWidget { +class EventListenerShell extends StatefulWidget { final Widget child; - const UploadQueueShell({super.key, required this.child}); + const EventListenerShell({super.key, required this.child}); @override - State createState() => _UploadQueueShellState(); + State createState() => _EventListenerShellState(); } -class _UploadQueueShellState extends State { +class _EventListenerShellState extends State + with WidgetsBindingObserver { StreamSubscription? _subscription; + StreamSubscription? _documentDeletedSubscription; + Timer? _inboxTimer; @override void initState() { super.initState(); + WidgetsBinding.instance.addObserver(this); ReceiveSharingIntent.getInitialMedia().then(_onReceiveSharedFiles); _subscription = ReceiveSharingIntent.getMediaStream().listen(_onReceiveSharedFiles); context.read().addListener(_onTasksChanged); - + _documentDeletedSubscription = + context.read().$deleted.listen((event) { + showSnackBar(context, S.of(context)!.documentSuccessfullyDeleted); + }); + _listenToInboxChanges(); // WidgetsBinding.instance.addPostFrameCallback((_) async { // final notifier = context.read(); // await notifier.isInitialized; @@ -67,6 +78,52 @@ class _UploadQueueShellState extends State { // }); } + void _listenToInboxChanges() { + final cubit = context.read(); + final currentUser = context.read(); + if (!currentUser.paperlessUser.canViewInbox || _inboxTimer != null) { + return; + } + cubit.refreshItemsInInboxCount(false); + _inboxTimer = Timer.periodic(30.seconds, (_) { + cubit.refreshItemsInInboxCount(false); + }); + } + + @override + void dispose() { + WidgetsBinding.instance.removeObserver(this); + _subscription?.cancel(); + _documentDeletedSubscription?.cancel(); + _inboxTimer?.cancel(); + super.dispose(); + } + + @override + void didChangeAppLifecycleState(AppLifecycleState state) { + switch (state) { + case AppLifecycleState.resumed: + debugPrint( + "App resumed, reloading connectivity and " + "restarting periodic query for inbox changes...", + ); + context.read().reload(); + _listenToInboxChanges(); + break; + case AppLifecycleState.inactive: + case AppLifecycleState.paused: + case AppLifecycleState.detached: + default: + _inboxTimer?.cancel(); + _inboxTimer = null; + debugPrint( + "App either paused or hidden, stopping " + "periodic query for inbox changes.", + ); + break; + } + } + void _onTasksChanged() { final taskNotifier = context.read(); final userId = context.read().id; @@ -96,12 +153,6 @@ class _UploadQueueShellState extends State { } } - @override - void dispose() { - _subscription?.cancel(); - super.dispose(); - } - @override Widget build(BuildContext context) { return widget.child; @@ -167,9 +218,9 @@ Future consumeLocalFile( ); await consumptionNotifier.discardFile(file, userId: userId); - if (result.taskId != null) { - taskNotifier.listenToTaskChanges(result.taskId!); - } + // if (result.taskId != null) { + // taskNotifier.listenToTaskChanges(result.taskId!); + // } if (exitAppAfterConsumed) { SystemNavigator.pop(); } diff --git a/lib/features/tasks/model/pending_tasks_notifier.dart b/lib/features/tasks/model/pending_tasks_notifier.dart index b1e1f03..f99837f 100644 --- a/lib/features/tasks/model/pending_tasks_notifier.dart +++ b/lib/features/tasks/model/pending_tasks_notifier.dart @@ -52,10 +52,10 @@ class PendingTasksNotifier extends ValueNotifier> { _subscriptions[taskId]?.cancel(); _subscriptions.remove(taskId); } else { - _subscriptions.forEach((key, value) { - value.cancel(); - _subscriptions.remove(key); - }); + for (var sub in _subscriptions.values) { + sub.cancel(); + } + _subscriptions.clear(); } } diff --git a/lib/helpers/file_helpers.dart b/lib/helpers/file_helpers.dart deleted file mode 100644 index 6c4577f..0000000 --- a/lib/helpers/file_helpers.dart +++ /dev/null @@ -1,3 +0,0 @@ -String extractFilenameFromPath(String path) { - return path.split(RegExp('[./]')).reversed.skip(1).first; -} diff --git a/lib/helpers/image_helpers.dart b/lib/helpers/image_helpers.dart deleted file mode 100644 index 05e8de7..0000000 --- a/lib/helpers/image_helpers.dart +++ /dev/null @@ -1,38 +0,0 @@ -// Taken from https://github.com/flutter/flutter/issues/26127#issuecomment-782083060 -import 'dart:async'; -import 'dart:ui'; - -import 'package:flutter/foundation.dart'; -import 'package:flutter/services.dart'; -import 'package:flutter/widgets.dart'; - -Future loadImage(ImageProvider provider) { - final config = ImageConfiguration( - bundle: rootBundle, - devicePixelRatio: window.devicePixelRatio, - platform: defaultTargetPlatform, - ); - final Completer completer = Completer(); - final ImageStream stream = provider.resolve(config); - - late final ImageStreamListener listener; - - listener = ImageStreamListener((ImageInfo image, bool sync) { - debugPrint("Image ${image.debugLabel} finished loading"); - completer.complete(); - stream.removeListener(listener); - }, onError: (dynamic exception, StackTrace? stackTrace) { - completer.complete(); - stream.removeListener(listener); - FlutterError.reportError(FlutterErrorDetails( - context: ErrorDescription('image failed to load'), - library: 'image resource service', - exception: exception, - stack: stackTrace, - silent: true, - )); - }); - - stream.addListener(listener); - return completer.future; -} diff --git a/lib/l10n/intl_ca.arb b/lib/l10n/intl_ca.arb index e170bb7..498a448 100644 --- a/lib/l10n/intl_ca.arb +++ b/lib/l10n/intl_ca.arb @@ -980,5 +980,21 @@ }, "tryAgain": "Try again", "discardFile": "Discard file?", - "discard": "Discard" + "discard": "Discard", + "backToLogin": "Back to login", + "skipEditingReceivedFiles": "Skip editing received files", + "uploadWithoutPromptingUploadForm": "Always upload without prompting the upload form when sharing files with the app.", + "authenticatingDots": "Authenticating...", + "@authenticatingDots": { + "description": "Message shown when the app is authenticating the user" + }, + "persistingUserInformation": "Persisting user information...", + "fetchingUserInformation": "Fetching user information...", + "@fetchingUserInformation": { + "description": "Message shown when the app loads user data from the server" + }, + "restoringSession": "Restoring session...", + "@restoringSession": { + "description": "Message shown when the user opens the app and the previous user is tried to be authenticated and logged in" + } } \ No newline at end of file diff --git a/lib/l10n/intl_cs.arb b/lib/l10n/intl_cs.arb index 015140a..c631625 100644 --- a/lib/l10n/intl_cs.arb +++ b/lib/l10n/intl_cs.arb @@ -980,5 +980,21 @@ }, "tryAgain": "Try again", "discardFile": "Discard file?", - "discard": "Discard" + "discard": "Discard", + "backToLogin": "Back to login", + "skipEditingReceivedFiles": "Skip editing received files", + "uploadWithoutPromptingUploadForm": "Always upload without prompting the upload form when sharing files with the app.", + "authenticatingDots": "Authenticating...", + "@authenticatingDots": { + "description": "Message shown when the app is authenticating the user" + }, + "persistingUserInformation": "Persisting user information...", + "fetchingUserInformation": "Fetching user information...", + "@fetchingUserInformation": { + "description": "Message shown when the app loads user data from the server" + }, + "restoringSession": "Restoring session...", + "@restoringSession": { + "description": "Message shown when the user opens the app and the previous user is tried to be authenticated and logged in" + } } \ No newline at end of file diff --git a/lib/l10n/intl_de.arb b/lib/l10n/intl_de.arb index 97f090a..5f947a6 100644 --- a/lib/l10n/intl_de.arb +++ b/lib/l10n/intl_de.arb @@ -980,5 +980,21 @@ }, "tryAgain": "Erneut versuchen", "discardFile": "Datei verwerfen?", - "discard": "Verwerfen" + "discard": "Verwerfen", + "backToLogin": "Zurück zur Anmeldung", + "skipEditingReceivedFiles": "Bearbeitung von empfangenen Dateien überspringen", + "uploadWithoutPromptingUploadForm": "Mit der App geteilte Dateien immer direkt hochladen, ohne das Upload-Formular anzuzeigen.", + "authenticatingDots": "Authentifizieren...", + "@authenticatingDots": { + "description": "Message shown when the app is authenticating the user" + }, + "persistingUserInformation": "Nutzerinformationen werden gespeichert...", + "fetchingUserInformation": "Benutzerinformationen werden abgerufen...", + "@fetchingUserInformation": { + "description": "Message shown when the app loads user data from the server" + }, + "restoringSession": "Sitzung wird wiederhergestellt...", + "@restoringSession": { + "description": "Message shown when the user opens the app and the previous user is tried to be authenticated and logged in" + } } \ No newline at end of file diff --git a/lib/l10n/intl_en.arb b/lib/l10n/intl_en.arb index db1778b..a89ae96 100644 --- a/lib/l10n/intl_en.arb +++ b/lib/l10n/intl_en.arb @@ -980,5 +980,21 @@ }, "tryAgain": "Try again", "discardFile": "Discard file?", - "discard": "Discard" + "discard": "Discard", + "backToLogin": "Back to login", + "skipEditingReceivedFiles": "Skip editing received files", + "uploadWithoutPromptingUploadForm": "Always upload without prompting the upload form when sharing files with the app.", + "authenticatingDots": "Authenticating...", + "@authenticatingDots": { + "description": "Message shown when the app is authenticating the user" + }, + "persistingUserInformation": "Persisting user information...", + "fetchingUserInformation": "Fetching user information...", + "@fetchingUserInformation": { + "description": "Message shown when the app loads user data from the server" + }, + "restoringSession": "Restoring session...", + "@restoringSession": { + "description": "Message shown when the user opens the app and the previous user is tried to be authenticated and logged in" + } } \ No newline at end of file diff --git a/lib/l10n/intl_es.arb b/lib/l10n/intl_es.arb index 3e0224f..068a86f 100644 --- a/lib/l10n/intl_es.arb +++ b/lib/l10n/intl_es.arb @@ -980,5 +980,21 @@ }, "tryAgain": "Try again", "discardFile": "Discard file?", - "discard": "Discard" + "discard": "Discard", + "backToLogin": "Back to login", + "skipEditingReceivedFiles": "Skip editing received files", + "uploadWithoutPromptingUploadForm": "Always upload without prompting the upload form when sharing files with the app.", + "authenticatingDots": "Authenticating...", + "@authenticatingDots": { + "description": "Message shown when the app is authenticating the user" + }, + "persistingUserInformation": "Persisting user information...", + "fetchingUserInformation": "Fetching user information...", + "@fetchingUserInformation": { + "description": "Message shown when the app loads user data from the server" + }, + "restoringSession": "Restoring session...", + "@restoringSession": { + "description": "Message shown when the user opens the app and the previous user is tried to be authenticated and logged in" + } } \ No newline at end of file diff --git a/lib/l10n/intl_fr.arb b/lib/l10n/intl_fr.arb index 778733d..da7ec87 100644 --- a/lib/l10n/intl_fr.arb +++ b/lib/l10n/intl_fr.arb @@ -980,5 +980,21 @@ }, "tryAgain": "Try again", "discardFile": "Discard file?", - "discard": "Discard" + "discard": "Discard", + "backToLogin": "Back to login", + "skipEditingReceivedFiles": "Skip editing received files", + "uploadWithoutPromptingUploadForm": "Always upload without prompting the upload form when sharing files with the app.", + "authenticatingDots": "Authenticating...", + "@authenticatingDots": { + "description": "Message shown when the app is authenticating the user" + }, + "persistingUserInformation": "Persisting user information...", + "fetchingUserInformation": "Fetching user information...", + "@fetchingUserInformation": { + "description": "Message shown when the app loads user data from the server" + }, + "restoringSession": "Restoring session...", + "@restoringSession": { + "description": "Message shown when the user opens the app and the previous user is tried to be authenticated and logged in" + } } \ No newline at end of file diff --git a/lib/l10n/intl_pl.arb b/lib/l10n/intl_pl.arb index 9153d4c..151cf85 100644 --- a/lib/l10n/intl_pl.arb +++ b/lib/l10n/intl_pl.arb @@ -980,5 +980,21 @@ }, "tryAgain": "Try again", "discardFile": "Discard file?", - "discard": "Discard" + "discard": "Discard", + "backToLogin": "Back to login", + "skipEditingReceivedFiles": "Skip editing received files", + "uploadWithoutPromptingUploadForm": "Always upload without prompting the upload form when sharing files with the app.", + "authenticatingDots": "Authenticating...", + "@authenticatingDots": { + "description": "Message shown when the app is authenticating the user" + }, + "persistingUserInformation": "Persisting user information...", + "fetchingUserInformation": "Fetching user information...", + "@fetchingUserInformation": { + "description": "Message shown when the app loads user data from the server" + }, + "restoringSession": "Restoring session...", + "@restoringSession": { + "description": "Message shown when the user opens the app and the previous user is tried to be authenticated and logged in" + } } \ No newline at end of file diff --git a/lib/l10n/intl_ru.arb b/lib/l10n/intl_ru.arb index f89e99d..96e417e 100644 --- a/lib/l10n/intl_ru.arb +++ b/lib/l10n/intl_ru.arb @@ -980,5 +980,21 @@ }, "tryAgain": "Try again", "discardFile": "Discard file?", - "discard": "Discard" + "discard": "Discard", + "backToLogin": "Back to login", + "skipEditingReceivedFiles": "Skip editing received files", + "uploadWithoutPromptingUploadForm": "Always upload without prompting the upload form when sharing files with the app.", + "authenticatingDots": "Authenticating...", + "@authenticatingDots": { + "description": "Message shown when the app is authenticating the user" + }, + "persistingUserInformation": "Persisting user information...", + "fetchingUserInformation": "Fetching user information...", + "@fetchingUserInformation": { + "description": "Message shown when the app loads user data from the server" + }, + "restoringSession": "Restoring session...", + "@restoringSession": { + "description": "Message shown when the user opens the app and the previous user is tried to be authenticated and logged in" + } } \ No newline at end of file diff --git a/lib/l10n/intl_tr.arb b/lib/l10n/intl_tr.arb index 7fa97a4..0fb7f09 100644 --- a/lib/l10n/intl_tr.arb +++ b/lib/l10n/intl_tr.arb @@ -980,5 +980,21 @@ }, "tryAgain": "Try again", "discardFile": "Discard file?", - "discard": "Discard" + "discard": "Discard", + "backToLogin": "Back to login", + "skipEditingReceivedFiles": "Skip editing received files", + "uploadWithoutPromptingUploadForm": "Always upload without prompting the upload form when sharing files with the app.", + "authenticatingDots": "Authenticating...", + "@authenticatingDots": { + "description": "Message shown when the app is authenticating the user" + }, + "persistingUserInformation": "Persisting user information...", + "fetchingUserInformation": "Fetching user information...", + "@fetchingUserInformation": { + "description": "Message shown when the app loads user data from the server" + }, + "restoringSession": "Restoring session...", + "@restoringSession": { + "description": "Message shown when the user opens the app and the previous user is tried to be authenticated and logged in" + } } \ No newline at end of file diff --git a/lib/main.dart b/lib/main.dart index bc36a56..ef08823 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -15,7 +15,6 @@ 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:mock_server/mock_server.dart'; import 'package:package_info_plus/package_info_plus.dart'; import 'package:paperless_api/paperless_api.dart'; import 'package:paperless_mobile/constants.dart'; @@ -28,7 +27,6 @@ import 'package:paperless_mobile/core/exception/server_message_exception.dart'; import 'package:paperless_mobile/core/factory/paperless_api_factory.dart'; import 'package:paperless_mobile/core/factory/paperless_api_factory_impl.dart'; import 'package:paperless_mobile/core/interceptor/language_header.interceptor.dart'; -import 'package:paperless_mobile/core/model/info_message_exception.dart'; import 'package:paperless_mobile/core/notifier/document_changed_notifier.dart'; import 'package:paperless_mobile/core/security/session_manager.dart'; import 'package:paperless_mobile/core/service/connectivity_status_service.dart'; @@ -36,11 +34,8 @@ import 'package:paperless_mobile/features/login/cubit/authentication_cubit.dart' import 'package:paperless_mobile/features/login/services/authentication_service.dart'; import 'package:paperless_mobile/features/notifications/services/local_notification_service.dart'; import 'package:paperless_mobile/features/settings/view/widgets/global_settings_builder.dart'; -import 'package:paperless_mobile/features/sharing/model/share_intent_queue.dart'; import 'package:paperless_mobile/generated/l10n/app_localizations.dart'; -import 'package:paperless_mobile/helpers/message_helpers.dart'; import 'package:paperless_mobile/routes/navigation_keys.dart'; -import 'package:paperless_mobile/routes/routes.dart'; import 'package:paperless_mobile/routes/typed/branches/documents_route.dart'; import 'package:paperless_mobile/routes/typed/branches/inbox_route.dart'; import 'package:paperless_mobile/routes/typed/branches/labels_route.dart'; @@ -50,12 +45,10 @@ import 'package:paperless_mobile/routes/typed/branches/scanner_route.dart'; import 'package:paperless_mobile/routes/typed/branches/upload_queue_route.dart'; import 'package:paperless_mobile/routes/typed/shells/provider_shell_route.dart'; import 'package:paperless_mobile/routes/typed/shells/scaffold_shell_route.dart'; -import 'package:paperless_mobile/routes/typed/top_level/checking_login_route.dart'; +import 'package:paperless_mobile/routes/typed/top_level/add_account_route.dart'; import 'package:paperless_mobile/routes/typed/top_level/logging_out_route.dart'; import 'package:paperless_mobile/routes/typed/top_level/login_route.dart'; import 'package:paperless_mobile/routes/typed/top_level/settings_route.dart'; -import 'package:paperless_mobile/routes/typed/top_level/switching_accounts_route.dart'; -import 'package:paperless_mobile/routes/typed/top_level/verify_identity_route.dart'; import 'package:paperless_mobile/theme.dart'; import 'package:path_provider/path_provider.dart'; import 'package:provider/provider.dart'; @@ -157,9 +150,8 @@ void main() async { apiFactory, sessionManager, connectivityStatusService, + localNotificationService, ); - await authenticationCubit.restoreSessionState(); - await ShareIntentQueue.instance.initialize(); runApp( MultiProvider( providers: [ @@ -208,11 +200,14 @@ class _GoRouterShellState extends State { @override void initState() { super.initState(); - FlutterNativeSplash.remove(); if (Platform.isAndroid) { _setOptimalDisplayMode(); } initializeDateFormatting(); + WidgetsBinding.instance.addPostFrameCallback((_) async { + context.read().restoreSession(); + FlutterNativeSplash.remove(); + }); } /// Activates the highest supported refresh rate on the device. @@ -236,43 +231,96 @@ class _GoRouterShellState extends State { debugLogDiagnostics: kDebugMode, initialLocation: "/login", routes: [ - $loginRoute, - $verifyIdentityRoute, - $switchingAccountsRoute, - $logginOutRoute, - $checkingLoginRoute, ShellRoute( + pageBuilder: (context, state, child) { + return MaterialPage( + child: BlocListener( + listener: (context, state) { + switch (state) { + case UnauthenticatedState( + redirectToAccountSelection: var shouldRedirect + ): + if (shouldRedirect) { + const LoginToExistingAccountRoute().go(context); + } else { + const LoginRoute().go(context); + } + break; + case RestoringSessionState(): + const RestoringSessionRoute().go(context); + break; + case VerifyIdentityState(userId: var userId): + VerifyIdentityRoute(userId: userId).go(context); + break; + case SwitchingAccountsState(): + const SwitchingAccountsRoute().push(context); + break; + case AuthenticatedState(): + const LandingRoute().go(context); + break; + case AuthenticatingState state: + AuthenticatingRoute(state.currentStage.name).push(context); + break; + case LoggingOutState(): + const LoggingOutRoute().go(context); + break; + case AuthenticationErrorState(): + if (context.canPop()) { + context.pop(); + } + // LoginRoute( + // $extra: errorState.clientCertificate, + // password: errorState.password, + // serverUrl: errorState.serverUrl, + // username: errorState.username, + // ).go(context); + break; + } + }, + child: child, + ), + ); + }, navigatorKey: rootNavigatorKey, - builder: ProviderShellRoute(widget.apiFactory).build, routes: [ - $settingsRoute, - $savedViewsRoute, - $uploadQueueRoute, - StatefulShellRoute( - navigatorContainerBuilder: (context, navigationShell, children) { - return children[navigationShell.currentIndex]; - }, - builder: const ScaffoldShellRoute().builder, - branches: [ - StatefulShellBranch( - navigatorKey: landingNavigatorKey, - routes: [$landingRoute], - ), - StatefulShellBranch( - navigatorKey: documentsNavigatorKey, - routes: [$documentsRoute], - ), - StatefulShellBranch( - navigatorKey: scannerNavigatorKey, - routes: [$scannerRoute], - ), - StatefulShellBranch( - navigatorKey: labelsNavigatorKey, - routes: [$labelsRoute], - ), - StatefulShellBranch( - navigatorKey: inboxNavigatorKey, - routes: [$inboxRoute], + $loginRoute, + $loggingOutRoute, + $addAccountRoute, + ShellRoute( + navigatorKey: outerShellNavigatorKey, + builder: ProviderShellRoute(widget.apiFactory).build, + routes: [ + $settingsRoute, + $savedViewsRoute, + $uploadQueueRoute, + StatefulShellRoute( + navigatorContainerBuilder: + (context, navigationShell, children) { + return children[navigationShell.currentIndex]; + }, + builder: const ScaffoldShellRoute().builder, + branches: [ + StatefulShellBranch( + navigatorKey: landingNavigatorKey, + routes: [$landingRoute], + ), + StatefulShellBranch( + navigatorKey: documentsNavigatorKey, + routes: [$documentsRoute], + ), + StatefulShellBranch( + navigatorKey: scannerNavigatorKey, + routes: [$scannerRoute], + ), + StatefulShellBranch( + navigatorKey: labelsNavigatorKey, + routes: [$labelsRoute], + ), + StatefulShellBranch( + navigatorKey: inboxNavigatorKey, + routes: [$inboxRoute], + ), + ], ), ], ), @@ -283,69 +331,34 @@ class _GoRouterShellState extends State { @override Widget build(BuildContext context) { - return BlocListener( - listener: (context, state) { - switch (state) { - case UnauthenticatedState(): - _router.goNamed(R.login); - break; - case RequiresLocalAuthenticationState(): - _router.goNamed(R.verifyIdentity); - break; - case SwitchingAccountsState(): - final userId = context.read().id; - context - .read() - .cancelUserNotifications(userId); - _router.goNamed(R.switchingAccounts); - break; - case AuthenticatedState(): - _router.goNamed(R.landing); - break; - case CheckingLoginState(): - _router.goNamed(R.checkingLogin); - break; - case LogginOutState(): - final userId = context.read().id; - context - .read() - .cancelUserNotifications(userId); - _router.goNamed(R.loggingOut); - break; - case AuthenticationErrorState(): - _router.goNamed(R.login); - break; - } + return GlobalSettingsBuilder( + builder: (context, settings) { + return DynamicColorBuilder( + builder: (lightDynamic, darkDynamic) { + return MaterialApp.router( + routerConfig: _router, + 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: S.localizationsDelegates, + ); + }, + ); }, - child: GlobalSettingsBuilder( - builder: (context, settings) { - return DynamicColorBuilder( - builder: (lightDynamic, darkDynamic) { - return MaterialApp.router( - routerConfig: _router, - 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: S.localizationsDelegates, - ); - }, - ); - }, - ), ); } } diff --git a/lib/routes/navigation_keys.dart b/lib/routes/navigation_keys.dart index 220cd4d..c99c21c 100644 --- a/lib/routes/navigation_keys.dart +++ b/lib/routes/navigation_keys.dart @@ -1,6 +1,7 @@ import 'package:flutter/material.dart'; final rootNavigatorKey = GlobalKey(); +final outerShellNavigatorKey = GlobalKey(); final landingNavigatorKey = GlobalKey(); final documentsNavigatorKey = GlobalKey(); final scannerNavigatorKey = GlobalKey(); diff --git a/lib/routes/routes.dart b/lib/routes/routes.dart index 9df4834..7f48599 100644 --- a/lib/routes/routes.dart +++ b/lib/routes/routes.dart @@ -2,9 +2,10 @@ class R { const R._(); static const landing = "landing"; static const login = "login"; + static const loginToExistingAccount = 'loginToExistingAccount'; static const documents = "documents"; static const verifyIdentity = "verifyIdentity"; - static const switchingAccounts = "switchingAccounts"; + static const switchingAccount = "switchingAccount"; static const savedView = "savedView"; static const createSavedView = "createSavedView"; static const editSavedView = "editSavedView"; @@ -21,6 +22,8 @@ class R { static const linkedDocuments = "linkedDocuments"; static const bulkEditDocuments = "bulkEditDocuments"; static const uploadQueue = "uploadQueue"; - static const checkingLogin = "checkingLogin"; + static const authenticating = "authenticating"; static const loggingOut = "loggingOut"; + static const restoringSession = "restoringSession"; + static const addAccount = 'addAccount'; } diff --git a/lib/routes/typed/branches/documents_route.dart b/lib/routes/typed/branches/documents_route.dart index d753128..9134681 100644 --- a/lib/routes/typed/branches/documents_route.dart +++ b/lib/routes/typed/branches/documents_route.dart @@ -54,7 +54,8 @@ class DocumentsRoute extends GoRouteData { } class DocumentDetailsRoute extends GoRouteData { - static final GlobalKey $parentNavigatorKey = rootNavigatorKey; + static final GlobalKey $parentNavigatorKey = + outerShellNavigatorKey; final bool isLabelClickable; final DocumentModel $extra; @@ -86,7 +87,8 @@ class DocumentDetailsRoute extends GoRouteData { } class EditDocumentRoute extends GoRouteData { - static final GlobalKey $parentNavigatorKey = rootNavigatorKey; + static final GlobalKey $parentNavigatorKey = + outerShellNavigatorKey; final DocumentModel $extra; @@ -114,7 +116,8 @@ class EditDocumentRoute extends GoRouteData { } class DocumentPreviewRoute extends GoRouteData { - static final GlobalKey $parentNavigatorKey = rootNavigatorKey; + static final GlobalKey $parentNavigatorKey = + outerShellNavigatorKey; final DocumentModel $extra; final String? title; diff --git a/lib/routes/typed/branches/labels_route.dart b/lib/routes/typed/branches/labels_route.dart index 44c0a6e..fa81e77 100644 --- a/lib/routes/typed/branches/labels_route.dart +++ b/lib/routes/typed/branches/labels_route.dart @@ -49,7 +49,8 @@ class LabelsRoute extends GoRouteData { } class EditLabelRoute extends GoRouteData { - static final GlobalKey $parentNavigatorKey = rootNavigatorKey; + static final GlobalKey $parentNavigatorKey = + outerShellNavigatorKey; final Label $extra; @@ -67,7 +68,8 @@ class EditLabelRoute extends GoRouteData { } class CreateLabelRoute extends GoRouteData { - static final GlobalKey $parentNavigatorKey = rootNavigatorKey; + static final GlobalKey $parentNavigatorKey = + outerShellNavigatorKey; final LabelType $extra; final String? name; @@ -88,7 +90,8 @@ class CreateLabelRoute extends GoRouteData { } class LinkedDocumentsRoute extends GoRouteData { - static final GlobalKey $parentNavigatorKey = rootNavigatorKey; + static final GlobalKey $parentNavigatorKey = + outerShellNavigatorKey; final DocumentFilter $extra; const LinkedDocumentsRoute(this.$extra); diff --git a/lib/routes/typed/branches/scanner_route.dart b/lib/routes/typed/branches/scanner_route.dart index c7ce700..ec72171 100644 --- a/lib/routes/typed/branches/scanner_route.dart +++ b/lib/routes/typed/branches/scanner_route.dart @@ -52,7 +52,8 @@ class ScannerRoute extends GoRouteData { } class DocumentUploadRoute extends GoRouteData { - static final GlobalKey $parentNavigatorKey = rootNavigatorKey; + static final GlobalKey $parentNavigatorKey = + outerShellNavigatorKey; final FutureOr $extra; final String? title; final String? filename; @@ -72,6 +73,7 @@ class DocumentUploadRoute extends GoRouteData { context.read(), context.read(), context.read(), + context.read(), ), child: DocumentUploadPreparationPage( title: title, diff --git a/lib/routes/typed/branches/upload_queue_route.dart b/lib/routes/typed/branches/upload_queue_route.dart index fa3327a..77521e5 100644 --- a/lib/routes/typed/branches/upload_queue_route.dart +++ b/lib/routes/typed/branches/upload_queue_route.dart @@ -11,7 +11,8 @@ part 'upload_queue_route.g.dart'; name: R.uploadQueue, ) class UploadQueueRoute extends GoRouteData { - static final GlobalKey $parentNavigatorKey = rootNavigatorKey; + static final GlobalKey $parentNavigatorKey = + outerShellNavigatorKey; @override Widget build(BuildContext context, GoRouterState state) { diff --git a/lib/routes/typed/shells/provider_shell_route.dart b/lib/routes/typed/shells/provider_shell_route.dart index 0a150b6..32b4f28 100644 --- a/lib/routes/typed/shells/provider_shell_route.dart +++ b/lib/routes/typed/shells/provider_shell_route.dart @@ -7,7 +7,7 @@ import 'package:paperless_mobile/core/database/tables/local_user_account.dart'; import 'package:paperless_mobile/core/factory/paperless_api_factory.dart'; import 'package:paperless_mobile/features/home/view/home_shell_widget.dart'; import 'package:paperless_mobile/features/sharing/cubit/receive_share_cubit.dart'; -import 'package:paperless_mobile/features/sharing/view/widgets/upload_queue_shell.dart'; +import 'package:paperless_mobile/features/sharing/view/widgets/event_listener_shell.dart'; import 'package:paperless_mobile/routes/navigation_keys.dart'; import 'package:provider/provider.dart'; @@ -50,7 +50,7 @@ import 'package:provider/provider.dart'; // ) class ProviderShellRoute extends ShellRouteData { final PaperlessApiFactory apiFactory; - static final GlobalKey $navigatorKey = rootNavigatorKey; + static final GlobalKey $navigatorKey = outerShellNavigatorKey; const ProviderShellRoute(this.apiFactory); @@ -77,7 +77,7 @@ class ProviderShellRoute extends ShellRouteData { child: ChangeNotifierProvider( create: (context) => ConsumptionChangeNotifier() ..loadFromConsumptionDirectory(userId: currentUserId), - child: UploadQueueShell(child: navigator), + child: EventListenerShell(child: navigator), ), ); } diff --git a/lib/routes/typed/top_level/add_account_route.dart b/lib/routes/typed/top_level/add_account_route.dart new file mode 100644 index 0000000..6c4375e --- /dev/null +++ b/lib/routes/typed/top_level/add_account_route.dart @@ -0,0 +1,80 @@ +import 'package:flutter/material.dart'; +import 'package:flutter/widgets.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:go_router/go_router.dart'; +import 'package:paperless_api/paperless_api.dart'; +import 'package:paperless_mobile/core/model/info_message_exception.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/view/add_account_page.dart'; +import 'package:paperless_mobile/features/settings/view/dialogs/switch_account_dialog.dart'; +import 'package:paperless_mobile/generated/l10n/app_localizations.dart'; +import 'package:paperless_mobile/helpers/message_helpers.dart'; +import 'package:paperless_mobile/routes/navigation_keys.dart'; +import 'package:paperless_mobile/routes/routes.dart'; + +part 'add_account_route.g.dart'; + +@TypedGoRoute( + path: '/add-account', + name: R.addAccount, +) +class AddAccountRoute extends GoRouteData { + const AddAccountRoute(); + + static final $parentNavigatorKey = rootNavigatorKey; + @override + Widget build(BuildContext context, GoRouterState state) { + return AddAccountPage( + titleText: S.of(context)!.addAccount, + onSubmit: + (context, username, password, serverUrl, clientCertificate) async { + try { + final userId = await context.read().addAccount( + credentials: LoginFormCredentials( + username: username, + password: password, + ), + clientCertificate: clientCertificate, + serverUrl: serverUrl, + enableBiometricAuthentication: false, + locale: Localizations.localeOf(context).languageCode, + ); + final shoudSwitch = await showDialog( + context: context, + builder: (context) => const SwitchAccountDialog(), + ) ?? + false; + if (shoudSwitch) { + await context.read().switchAccount(userId); + } else { + while (context.canPop()) { + context.pop(); + } + } + } on PaperlessApiException catch (error, stackTrace) { + showErrorMessage(context, error, stackTrace); + // context.pop(); + } on PaperlessFormValidationException catch (exception, stackTrace) { + if (exception.hasUnspecificErrorMessage()) { + showLocalizedError(context, exception.unspecificErrorMessage()!); + // context.pop(); + } else { + showGenericError( + context, + exception.validationMessages.values.first, + stackTrace, + ); //TODO: Check if we can show error message directly on field here. + } + } on InfoMessageException catch (error) { + showInfoMessage(context, error); + // context.pop(); + } catch (unknownError, stackTrace) { + showGenericError(context, unknownError.toString(), stackTrace); + // context.pop(); + } + }, + submitText: S.of(context)!.addAccount, + ); + } +} diff --git a/lib/routes/typed/top_level/checking_login_route.dart b/lib/routes/typed/top_level/checking_login_route.dart deleted file mode 100644 index 1a9d62b..0000000 --- a/lib/routes/typed/top_level/checking_login_route.dart +++ /dev/null @@ -1,23 +0,0 @@ -import 'dart:async'; - -import 'package:flutter/material.dart'; -import 'package:go_router/go_router.dart'; -import 'package:paperless_mobile/routes/routes.dart'; - -part 'checking_login_route.g.dart'; - -@TypedGoRoute( - path: "/checking-login", - name: R.checkingLogin, -) -class CheckingLoginRoute extends GoRouteData { - const CheckingLoginRoute(); - @override - Widget build(BuildContext context, GoRouterState state) { - return Scaffold( - body: Center( - child: Text("Logging in..."), - ), - ); - } -} diff --git a/lib/routes/typed/top_level/logging_out_route.dart b/lib/routes/typed/top_level/logging_out_route.dart index d584be9..e78479e 100644 --- a/lib/routes/typed/top_level/logging_out_route.dart +++ b/lib/routes/typed/top_level/logging_out_route.dart @@ -6,12 +6,12 @@ import 'package:paperless_mobile/routes/routes.dart'; part 'logging_out_route.g.dart'; -@TypedGoRoute( +@TypedGoRoute( path: "/logging-out", name: R.loggingOut, ) -class LogginOutRoute extends GoRouteData { - const LogginOutRoute(); +class LoggingOutRoute extends GoRouteData { + const LoggingOutRoute(); @override Widget build(BuildContext context, GoRouterState state) { return Scaffold( diff --git a/lib/routes/typed/top_level/login_route.dart b/lib/routes/typed/top_level/login_route.dart index ce6cc8f..d8bf146 100644 --- a/lib/routes/typed/top_level/login_route.dart +++ b/lib/routes/typed/top_level/login_route.dart @@ -1,10 +1,18 @@ import 'dart:async'; -import 'package:flutter/src/widgets/framework.dart'; +import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:go_router/go_router.dart'; +import 'package:hive_flutter/adapters.dart'; +import 'package:paperless_mobile/core/config/hive/hive_extensions.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/view/login_page.dart'; +import 'package:paperless_mobile/features/login/view/login_to_existing_account_page.dart'; +import 'package:paperless_mobile/features/login/view/verify_identity_page.dart'; +import 'package:paperless_mobile/features/login/view/widgets/login_transition_page.dart'; +import 'package:paperless_mobile/generated/l10n/app_localizations.dart'; +import 'package:paperless_mobile/routes/navigation_keys.dart'; import 'package:paperless_mobile/routes/routes.dart'; part 'login_route.g.dart'; @@ -12,12 +20,51 @@ part 'login_route.g.dart'; @TypedGoRoute( path: "/login", name: R.login, + routes: [ + TypedGoRoute( + path: "switching-account", + name: R.switchingAccount, + ), + TypedGoRoute( + path: 'authenticating', + name: R.authenticating, + ), + TypedGoRoute( + path: 'verify-identity', + name: R.verifyIdentity, + ), + TypedGoRoute( + path: 'existing', + name: R.loginToExistingAccount, + ), + TypedGoRoute( + path: 'restoring-session', + name: R.restoringSession, + ), + ], ) class LoginRoute extends GoRouteData { - const LoginRoute(); + static final $parentNavigatorKey = rootNavigatorKey; + final String? serverUrl; + final String? username; + final String? password; + final ClientCertificate? $extra; + + const LoginRoute({ + this.serverUrl, + this.username, + this.password, + this.$extra, + }); + @override Widget build(BuildContext context, GoRouterState state) { - return const LoginPage(); + return LoginPage( + initialServerUrl: serverUrl, + initialUsername: username, + initialPassword: password, + initialClientCertificate: $extra, + ); } @override @@ -28,3 +75,77 @@ class LoginRoute extends GoRouteData { return null; } } + +class SwitchingAccountsRoute extends GoRouteData { + static final $parentNavigatorKey = rootNavigatorKey; + + const SwitchingAccountsRoute(); + @override + Widget build(BuildContext context, GoRouterState state) { + return LoginTransitionPage( + text: S.of(context)!.switchingAccountsPleaseWait, + ); + } +} + +class AuthenticatingRoute extends GoRouteData { + static final $parentNavigatorKey = rootNavigatorKey; + + final String checkLoginStageName; + const AuthenticatingRoute(this.checkLoginStageName); + @override + Widget build(BuildContext context, GoRouterState state) { + final stage = AuthenticatingStage.values.byName(checkLoginStageName); + final text = switch (stage) { + AuthenticatingStage.authenticating => S.of(context)!.authenticatingDots, + AuthenticatingStage.persistingLocalUserData => + S.of(context)!.persistingUserInformation, + AuthenticatingStage.fetchingUserInformation => + S.of(context)!.fetchingUserInformation, + }; + + return LoginTransitionPage(text: text); + } +} + +class VerifyIdentityRoute extends GoRouteData { + static final $parentNavigatorKey = rootNavigatorKey; + + final String userId; + const VerifyIdentityRoute({required this.userId}); + + @override + Widget build(BuildContext context, GoRouterState state) { + return VerifyIdentityPage(userId: userId); + } +} + +class LoginToExistingAccountRoute extends GoRouteData { + static final $parentNavigatorKey = rootNavigatorKey; + + const LoginToExistingAccountRoute(); + + @override + FutureOr redirect(BuildContext context, GoRouterState state) { + if (Hive.localUserAccountBox.isEmpty) { + return "/login"; + } + return null; + } + + @override + Widget build(BuildContext context, GoRouterState state) { + return const LoginToExistingAccountPage(); + } +} + +class RestoringSessionRoute extends GoRouteData { + static final $parentNavigatorKey = rootNavigatorKey; + + const RestoringSessionRoute(); + + @override + Widget build(BuildContext context, GoRouterState state) { + return LoginTransitionPage(text: S.of(context)!.restoringSession); + } +} diff --git a/lib/routes/typed/top_level/settings_route.dart b/lib/routes/typed/top_level/settings_route.dart index 3286c00..8a93e74 100644 --- a/lib/routes/typed/top_level/settings_route.dart +++ b/lib/routes/typed/top_level/settings_route.dart @@ -13,7 +13,7 @@ part 'settings_route.g.dart'; name: R.settings, ) class SettingsRoute extends GoRouteData { - static final GlobalKey $parentNavigatorKey = rootNavigatorKey; + static final GlobalKey $parentNavigatorKey = outerShellNavigatorKey; @override Widget build(BuildContext context, GoRouterState state) { diff --git a/lib/routes/typed/top_level/switching_accounts_route.dart b/lib/routes/typed/top_level/switching_accounts_route.dart deleted file mode 100644 index 1dc74dc..0000000 --- a/lib/routes/typed/top_level/switching_accounts_route.dart +++ /dev/null @@ -1,18 +0,0 @@ -import 'package:flutter/widgets.dart'; -import 'package:go_router/go_router.dart'; -import 'package:paperless_mobile/features/settings/view/pages/switching_accounts_page.dart'; -import 'package:paperless_mobile/routes/routes.dart'; - -part 'switching_accounts_route.g.dart'; - -@TypedGoRoute( - path: '/switching-accounts', - name: R.switchingAccounts, -) -class SwitchingAccountsRoute extends GoRouteData { - const SwitchingAccountsRoute(); - @override - Widget build(BuildContext context, GoRouterState state) { - return const SwitchingAccountsPage(); - } -} diff --git a/lib/routes/typed/top_level/verify_identity_route.dart b/lib/routes/typed/top_level/verify_identity_route.dart deleted file mode 100644 index 5e62dd1..0000000 --- a/lib/routes/typed/top_level/verify_identity_route.dart +++ /dev/null @@ -1,19 +0,0 @@ -import 'package:go_router/go_router.dart'; -import 'package:flutter/widgets.dart'; -import 'package:paperless_mobile/features/home/view/widget/verify_identity_page.dart'; -import 'package:paperless_mobile/routes/routes.dart'; - -part 'verify_identity_route.g.dart'; - -@TypedGoRoute( - path: '/verify-identity', - name: R.verifyIdentity, -) -class VerifyIdentityRoute extends GoRouteData { - const VerifyIdentityRoute(); - - @override - Widget build(BuildContext context, GoRouterState state) { - return const VerifyIdentityPage(); - } -} diff --git a/packages/paperless_api/lib/src/models/exception/paperless_form_validation_exception.dart b/packages/paperless_api/lib/src/models/exception/paperless_form_validation_exception.dart index 5599308..b300b54 100644 --- a/packages/paperless_api/lib/src/models/exception/paperless_form_validation_exception.dart +++ b/packages/paperless_api/lib/src/models/exception/paperless_form_validation_exception.dart @@ -20,7 +20,8 @@ class PaperlessFormValidationException implements Exception { } static bool canParse(Map json) { - return json.values.every((element) => element is String); + return json.values + .every((element) => element is String || element is List); } factory PaperlessFormValidationException.fromJson(Map json) { diff --git a/packages/paperless_api/lib/src/modules/authentication_api/authentication_api_impl.dart b/packages/paperless_api/lib/src/modules/authentication_api/authentication_api_impl.dart index 631fbc3..3ca527a 100644 --- a/packages/paperless_api/lib/src/modules/authentication_api/authentication_api_impl.dart +++ b/packages/paperless_api/lib/src/modules/authentication_api/authentication_api_impl.dart @@ -20,13 +20,15 @@ class PaperlessAuthenticationApiImpl implements PaperlessAuthenticationApi { "password": password, }, options: Options( + sendTimeout: const Duration(seconds: 5), + receiveTimeout: const Duration(seconds: 5), followRedirects: false, headers: { "Accept": "application/json", }, - validateStatus: (status) { - return status! == 200; - }, + // validateStatus: (status) { + // return status! == 200; + // }, ), ); return response.data['token']; diff --git a/packages/paperless_api/lib/src/modules/documents_api/paperless_documents_api.dart b/packages/paperless_api/lib/src/modules/documents_api/paperless_documents_api.dart index 4227f83..09560aa 100644 --- a/packages/paperless_api/lib/src/modules/documents_api/paperless_documents_api.dart +++ b/packages/paperless_api/lib/src/modules/documents_api/paperless_documents_api.dart @@ -29,7 +29,7 @@ abstract class PaperlessDocumentsApi { DocumentModel document, String localFilePath, { bool original = false, - void Function(double)? onProgressChanged, + void Function(double progress)? onProgressChanged, }); Future findSuggestions(DocumentModel document); diff --git a/packages/paperless_api/lib/src/modules/documents_api/paperless_documents_api_impl.dart b/packages/paperless_api/lib/src/modules/documents_api/paperless_documents_api_impl.dart index 016a260..25aabaa 100644 --- a/packages/paperless_api/lib/src/modules/documents_api/paperless_documents_api_impl.dart +++ b/packages/paperless_api/lib/src/modules/documents_api/paperless_documents_api_impl.dart @@ -23,6 +23,7 @@ class PaperlessDocumentsApiImpl implements PaperlessDocumentsApi { int? correspondent, Iterable tags = const [], int? asn, + void Function(double progress)? onProgressChanged, }) async { final formData = FormData(); formData.files.add( @@ -55,7 +56,7 @@ class PaperlessDocumentsApiImpl implements PaperlessDocumentsApi { '/api/documents/post_document/', data: formData, onSendProgress: (count, total) { - debugPrint("Uploading ${(count / total) * 100}%..."); + onProgressChanged?.call(count.toDouble() / total.toDouble()); }, options: Options(validateStatus: (status) => status == 200), ); diff --git a/pubspec.yaml b/pubspec.yaml index a77281d..1e5f856 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -15,7 +15,7 @@ publish_to: "none" # Remove this line if you wish to publish to pub.dev # In iOS, build-name is used as CFBundleShortVersionString while build-number used as CFBundleVersion. # Read more about iOS versioning at # https://developer.apple.com/library/archive/documentation/General/Reference/InfoPlistKeyReference/Articles/CoreFoundationKeys.html -version: 2.3.12+47 +version: 3.0.0+47 environment: sdk: ">=3.0.0 <4.0.0" @@ -50,7 +50,7 @@ dependencies: cached_network_image: ^3.2.1 shimmer: ^2.0.0 flutter_bloc: ^8.1.1 - equatable: ^2.0.3 + equatable: ^2.0.5 flutter_form_builder: ^8.0.0 package_info_plus: ^4.0.1 font_awesome_flutter: ^10.1.0