feat: bugfixes, finished go_router migration, implemented better visibility of states

This commit is contained in:
Anton Stubenbord
2023-10-06 01:17:08 +02:00
parent ad23df4f89
commit a2c5ced3b7
102 changed files with 1512 additions and 3090 deletions

View File

@@ -1,20 +0,0 @@
import 'dart:async';
import 'package:flutter/foundation.dart';
class GoRouterRefreshStream extends ChangeNotifier {
GoRouterRefreshStream(Stream<dynamic> stream) {
notifyListeners();
_subscription = stream.asBroadcastStream().listen(
(dynamic _) => notifyListeners(),
);
}
late final StreamSubscription<dynamic> _subscription;
@override
void dispose() {
_subscription.cancel();
super.dispose();
}
}

View File

@@ -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<DocumentProcessingStatus?> {
DocumentStatusCubit() : super(null);
void updateStatus(DocumentProcessingStatus? status) => emit(status);
}

View File

@@ -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<void> pushSavedViewDetailsRoute(
BuildContext context, {
required SavedView savedView,
}) {
final apiVersion = context.read<ApiVersion>();
return Navigator.of(context).push(
MaterialPageRoute(
builder: (_) => MultiProvider(
providers: [
Provider.value(value: apiVersion),
if (context.watch<LocalUserAccount>().hasMultiUserSupport)
Provider.value(value: context.read<UserRepository>()),
Provider.value(value: context.read<LabelRepository>()),
Provider.value(value: context.read<DocumentChangedNotifier>()),
Provider.value(value: context.read<PaperlessDocumentsApi>()),
Provider.value(value: context.read<CacheManager>()),
Provider.value(value: context.read<ConnectivityCubit>()),
],
builder: (_, child) {
return BlocProvider(
create: (context) => SavedViewDetailsCubit(
context.read(),
context.read(),
context.read(),
LocalUserAppState.current,
context.read(),
savedView: savedView,
),
child: SavedViewDetailsPage(
onDelete: context.read<SavedViewCubit>().remove,
),
);
},
),
),
);
}
Future<void> pushBulkEditCorrespondentRoute(
BuildContext context, {
required List<DocumentModel> 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<DocumentBulkActionCubit, DocumentBulkActionState>(
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<DocumentBulkActionCubit>()
.bulkModifyCorrespondent,
assignMessageBuilder: (int count, String name) {
return S.of(context)!.bulkEditCorrespondentAssignMessage(
name,
count,
);
},
removeMessageBuilder: (int count) {
return S
.of(context)!
.bulkEditCorrespondentRemoveMessage(count);
},
);
},
),
),
),
),
);
}
Future<void> pushBulkEditStoragePathRoute(
BuildContext context, {
required List<DocumentModel> 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<DocumentBulkActionCubit, DocumentBulkActionState>(
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<DocumentBulkActionCubit>()
.bulkModifyStoragePath,
assignMessageBuilder: (int count, String name) {
return S.of(context)!.bulkEditStoragePathAssignMessage(
count,
name,
);
},
removeMessageBuilder: (int count) {
return S.of(context)!.bulkEditStoragePathRemoveMessage(count);
},
);
},
),
),
),
),
);
}
Future<void> pushBulkEditTagsRoute(
BuildContext context, {
required List<DocumentModel> 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<void> pushBulkEditDocumentTypeRoute(BuildContext context,
{required List<DocumentModel> 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<DocumentBulkActionCubit, DocumentBulkActionState>(
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<DocumentBulkActionCubit>()
.bulkModifyDocumentType,
assignMessageBuilder: (int count, String name) {
return S.of(context)!.bulkEditDocumentTypeAssignMessage(
count,
name,
);
},
removeMessageBuilder: (int count) {
return S
.of(context)!
.bulkEditDocumentTypeRemoveMessage(count);
},
);
},
),
),
),
),
);
}
List<Provider> _getRequiredBulkEditProviders(BuildContext context) {
return [
Provider.value(value: context.read<PaperlessDocumentsApi>()),
Provider.value(value: context.read<LabelRepository>()),
Provider.value(value: context.read<DocumentChangedNotifier>()),
];
}

View File

@@ -12,6 +12,10 @@ class DocumentChangedNotifier {
final Map<dynamic, List<StreamSubscription>> _subscribers = {}; final Map<dynamic, List<StreamSubscription>> _subscribers = {};
Stream<DocumentModel> get $updated => _updated.asBroadcastStream();
Stream<DocumentModel> get $deleted => _deleted.asBroadcastStream();
void notifyUpdated(DocumentModel updated) { void notifyUpdated(DocumentModel updated) {
debugPrint("Notifying updated document ${updated.id}"); debugPrint("Notifying updated document ${updated.id}");
_updated.add(updated); _updated.add(updated);

View File

@@ -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,
);
}
}

View File

@@ -1,7 +1,6 @@
import 'dart:io'; import 'dart:io';
import 'package:flutter/foundation.dart'; import 'package:flutter/foundation.dart';
import 'package:paperless_api/paperless_api.dart';
import 'package:path_provider/path_provider.dart'; import 'package:path_provider/path_provider.dart';
import 'package:rxdart/rxdart.dart'; import 'package:rxdart/rxdart.dart';
import 'package:uuid/uuid.dart'; import 'package:uuid/uuid.dart';
@@ -14,9 +13,6 @@ class FileService {
String filename, String filename,
) async { ) async {
final dir = await documentsDirectory; final dir = await documentsDirectory;
if (dir == null) {
throw const PaperlessApiException.unknown(); //TODO: better handling
}
File file = File("${dir.path}/$filename"); File file = File("${dir.path}/$filename");
return file..writeAsBytes(bytes); return file..writeAsBytes(bytes);
} }
@@ -43,7 +39,7 @@ class FileService {
static Future<Directory> get temporaryDirectory => getTemporaryDirectory(); static Future<Directory> get temporaryDirectory => getTemporaryDirectory();
static Future<Directory?> get documentsDirectory async { static Future<Directory> get documentsDirectory async {
if (Platform.isAndroid) { if (Platform.isAndroid) {
return (await getExternalStorageDirectories( return (await getExternalStorageDirectories(
type: StorageDirectory.documents, type: StorageDirectory.documents,

View File

@@ -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<void> startListeningBeforeDocumentUpload(
String httpUrl, UserCredentials credentials, String documentFileName);
}
class WebSocketStatusService implements StatusService {
late WebSocket? socket;
// late IOWebSocketChannel? _channel;
WebSocketStatusService();
@override
Future<void> startListeningBeforeDocumentUpload(
String httpUrl,
UserCredentials credentials,
String documentFileName,
) async {
// socket = await WebSocket.connect(
// httpUrl.replaceFirst("http", "ws") + "/ws/status/",
// customClient: getIt<HttpClient>(),
// 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<DocumentStatusCubit>().updateStatus(status);
// if (status.currentProgress == 100) {
// socket!.close();
// }
// });
// }
}
}
class LongPollingStatusService implements StatusService {
final Dio client;
const LongPollingStatusService(this.client);
@override
Future<void> startListeningBeforeDocumentUpload(
String httpUrl,
UserCredentials credentials,
String documentFileName,
) async {
// final today = DateTime.now();
// bool consumptionFinished = false;
// int retryCount = 0;
// getIt<DocumentStatusCubit>().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<DocumentStatusCubit>().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);
}
}

View File

@@ -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<AppPopupMenuEntries> displayedActions;
// const AppOptionsPopupMenu({
// super.key,
// required this.displayedActions,
// });
// @override
// Widget build(BuildContext context) {
// return PopupMenuButton<AppPopupMenuEntries>(
// position: PopupMenuPosition.under,
// icon: const Icon(Icons.more_vert),
// onSelected: (action) {
// switch (action) {
// case AppPopupMenuEntries.documentsSelectListView:
// context.read<ApplicationSettingsCubit>().setViewType(ViewType.list);
// break;
// case AppPopupMenuEntries.documentsSelectGridView:
// context.read<ApplicationSettingsCubit>().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<ApplicationSettingsCubit>(),
// child: const SettingsPage(),
// ),
// ),
// );
// break;
// case AppPopupMenuEntries.reportBug:
// launchUrlString(
// 'https://github.com/astubenbord/paperless-mobile/issues/new',
// );
// break;
// default:
// break;
// }
// },
// itemBuilder: _buildEntries,
// );
// }
// PopupMenuItem<AppPopupMenuEntries> _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<AppPopupMenuEntries> _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<AppPopupMenuEntries> _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<AppPopupMenuEntries> _buildListViewTile() {
// return PopupMenuItem(
// padding: EdgeInsets.zero,
// child: BlocBuilder<ApplicationSettingsCubit, ApplicationSettingsState>(
// 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<AppPopupMenuEntries> _buildGridViewTile() {
// return PopupMenuItem(
// value: AppPopupMenuEntries.documentsSelectGridView,
// padding: EdgeInsets.zero,
// child: BlocBuilder<ApplicationSettingsCubit, ApplicationSettingsState>(
// 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<PopupMenuEntry<AppPopupMenuEntries>> _buildEntries(
// BuildContext context) {
// List<PopupMenuEntry<AppPopupMenuEntries>> 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;
// }
// }

View File

@@ -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<T> = String Function(T suggestion);
/// Text field that auto-completes user input from a list of items
class FormBuilderTypeAhead<T> extends FormBuilderField<T> {
/// 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<T> 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<T>? 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<T> 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<T>? 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<T>? onSaved,
FormFieldValidator<T>? validator,
InputDecoration decoration = const InputDecoration(),
required String name,
required this.itemBuilder,
required this.suggestionsCallback,
T? initialValue,
ValueChanged<T?>? onChanged,
ValueTransformer<T?>? 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<T?> field) {
final state = field as FormBuilderTypeAheadState<T>;
final theme = Theme.of(state.context);
return TypeAheadField<T>(
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<T> createState() => FormBuilderTypeAheadState<T>();
}
class FormBuilderTypeAheadState<T>
extends FormBuilderFieldState<FormBuilderTypeAhead<T>, 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;
}
}

View File

@@ -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<T> = Future<List<T>> Function(String query);
typedef ChipSelected<T> = void Function(T data, bool selected);
typedef ChipsBuilder<T> = Widget Function(
BuildContext context, ChipsInputState<T> state, T data);
class ChipsInput<T> 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<T> findSuggestions;
final ValueChanged<List<T>> onChanged;
final ValueChanged<T>? onChipTapped;
final ChipsBuilder<T> chipBuilder;
final ChipsBuilder<T> suggestionBuilder;
@override
ChipsInputState<T> createState() => ChipsInputState<T>();
}
class ChipsInputState<T> extends State<ChipsInput<T>> {
static const kObjectReplacementChar = 0xFFFC;
Set<T> _chips = {};
List<T> _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<Widget>(
(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: <Widget>[
Text(
text,
style: theme.textTheme.bodyLarge?.copyWith(
height: 1.5,
),
),
_TextCaret(
resumed: _focusNode.hasFocus,
),
],
),
),
);
return Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
//mainAxisSize: MainAxisSize.min,
children: <Widget>[
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,
),
),
);
}
}

View File

@@ -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<T extends Label> = Map<int, T> Function(
DocumentBulkActionState state);
class BulkEditLabelBottomSheet<T extends Label> extends StatefulWidget {
final String title;
final String formFieldLabel;
final Widget formFieldPrefixIcon;
final LabelOptionsSelector<T> 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<BulkEditLabelBottomSheet<T>> createState() =>
_BulkEditLabelBottomSheetState<T>();
}
class _BulkEditLabelBottomSheetState<T extends Label>
extends State<BulkEditLabelBottomSheet<T>> {
final _formKey = GlobalKey<FormBuilderState>();
@override
Widget build(BuildContext context) {
return Padding(
padding:
EdgeInsets.only(bottom: MediaQuery.of(context).viewInsets.bottom),
child: BlocBuilder<DocumentBulkActionCubit, DocumentBulkActionState>(
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<T>(
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),
],
),
),
);
},
),
);
}
}

View File

@@ -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),
// );
// }
// }

View File

@@ -6,16 +6,14 @@ import 'package:flutter/material.dart';
import 'package:freezed_annotation/freezed_annotation.dart'; import 'package:freezed_annotation/freezed_annotation.dart';
import 'package:open_filex/open_filex.dart'; import 'package:open_filex/open_filex.dart';
import 'package:paperless_api/paperless_api.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/notifier/document_changed_notifier.dart';
import 'package:paperless_mobile/core/repository/label_repository.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/core/service/file_service.dart';
import 'package:paperless_mobile/features/notifications/services/local_notification_service.dart'; import 'package:paperless_mobile/features/notifications/services/local_notification_service.dart';
import 'package:printing/printing.dart'; import 'package:printing/printing.dart';
import 'package:share_plus/share_plus.dart'; import 'package:share_plus/share_plus.dart';
import 'package:cross_file/cross_file.dart'; import 'package:cross_file/cross_file.dart';
import 'package:path/path.dart' as p;
part 'document_details_cubit.freezed.dart'; part 'document_details_cubit.freezed.dart';
part 'document_details_state.dart'; part 'document_details_state.dart';
@@ -94,11 +92,9 @@ class DocumentDetailsCubit extends Cubit<DocumentDetailsState> {
if (state.metaData == null) { if (state.metaData == null) {
await loadMetaData(); await loadMetaData();
} }
final desc = FileDescription.fromPath( final filePath = state.metaData!.mediaFilename.replaceAll("/", " ");
state.metaData!.mediaFilename.replaceAll("/", " "),
);
final fileName = "${desc.filename}.pdf"; final fileName = "${p.basenameWithoutExtension(filePath)}.pdf";
final file = File("${cacheDir.path}/$fileName"); final file = File("${cacheDir.path}/$fileName");
if (!file.existsSync()) { if (!file.existsSync()) {
@@ -126,50 +122,58 @@ class DocumentDetailsCubit extends Cubit<DocumentDetailsState> {
if (state.metaData == null) { if (state.metaData == null) {
await loadMetaData(); await loadMetaData();
} }
String filePath = _buildDownloadFilePath( String targetPath = _buildDownloadFilePath(
downloadOriginal, downloadOriginal,
await FileService.downloadsDirectory, await FileService.downloadsDirectory,
); );
final desc = FileDescription.fromPath(
state.metaData!.mediaFilename if (!await File(targetPath).exists()) {
.replaceAll("/", " "), // Flatten directory structure await File(targetPath).create();
);
if (!File(filePath).existsSync()) {
File(filePath).createSync();
} else { } else {
return _notificationService.notifyFileDownload( await _notificationService.notifyFileDownload(
document: state.document, document: state.document,
filename: "${desc.filename}.${desc.extension}", filename: p.basename(targetPath),
filePath: filePath, filePath: targetPath,
finished: true, finished: true,
locale: locale, locale: locale,
userId: userId, userId: userId,
); );
} }
await _notificationService.notifyFileDownload( // await _notificationService.notifyFileDownload(
document: state.document, // document: state.document,
filename: "${desc.filename}.${desc.extension}", // filename: p.basename(targetPath),
filePath: filePath, // filePath: targetPath,
finished: false, // finished: false,
locale: locale, // locale: locale,
userId: userId, // userId: userId,
); // );
await _api.downloadToFile( await _api.downloadToFile(
state.document, state.document,
filePath, targetPath,
original: downloadOriginal, 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( await _notificationService.notifyFileDownload(
document: state.document, document: state.document,
filename: "${desc.filename}.${desc.extension}", filename: p.basename(targetPath),
filePath: filePath, filePath: targetPath,
finished: true, finished: true,
locale: locale, locale: locale,
userId: userId, userId: userId,
); );
debugPrint("Downloaded file to $filePath"); debugPrint("Downloaded file to $targetPath");
} }
Future<void> shareDocument({bool shareOriginal = false}) async { Future<void> shareDocument({bool shareOriginal = false}) async {
@@ -220,12 +224,9 @@ class DocumentDetailsCubit extends Cubit<DocumentDetailsState> {
} }
String _buildDownloadFilePath(bool original, Directory dir) { String _buildDownloadFilePath(bool original, Directory dir) {
final description = FileDescription.fromPath( final normalizedPath = state.metaData!.mediaFilename.replaceAll("/", " ");
state.metaData!.mediaFilename final extension = original ? p.extension(normalizedPath) : '.pdf';
.replaceAll("/", " "), // Flatten directory structure return "${dir.path}/${p.basenameWithoutExtension(normalizedPath)}$extension";
);
final extension = original ? description.extension : 'pdf';
return "${dir.path}/${description.filename}.$extension";
} }
@override @override

View File

@@ -1,4 +1,3 @@
import 'package:connectivity_plus/connectivity_plus.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:go_router/go_router.dart'; import 'package:go_router/go_router.dart';
@@ -48,7 +47,7 @@ class _DocumentDetailsPageState extends State<DocumentDetailsPage> {
Widget build(BuildContext context) { Widget build(BuildContext context) {
final hasMultiUserSupport = final hasMultiUserSupport =
context.watch<LocalUserAccount>().hasMultiUserSupport; context.watch<LocalUserAccount>().hasMultiUserSupport;
final tabLength = 4 + (hasMultiUserSupport ? 1 : 0); final tabLength = 4 + (hasMultiUserSupport && false ? 1 : 0);
return WillPopScope( return WillPopScope(
onWillPop: () async { onWillPop: () async {
Navigator.of(context) Navigator.of(context)
@@ -86,51 +85,52 @@ class _DocumentDetailsPageState extends State<DocumentDetailsPage> {
collapsedHeight: kToolbarHeight, collapsedHeight: kToolbarHeight,
expandedHeight: 250.0, expandedHeight: 250.0,
flexibleSpace: FlexibleSpaceBar( flexibleSpace: FlexibleSpaceBar(
background: Stack( background: BlocBuilder<DocumentDetailsCubit,
alignment: Alignment.topCenter, DocumentDetailsState>(
children: [ builder: (context, state) {
BlocBuilder<DocumentDetailsCubit, return Hero(
DocumentDetailsState>( tag: "thumb_${state.document.id}",
builder: (context, state) { child: GestureDetector(
return Positioned.fill( onTap: () {
child: GestureDetector( DocumentPreviewRoute($extra: state.document)
onTap: () { .push(context);
DocumentPreviewRoute($extra: state.document) },
.push(context); child: Stack(
}, alignment: Alignment.topCenter,
child: DocumentPreview( children: [
document: state.document, Positioned.fill(
fit: BoxFit.cover, child: DocumentPreview(
enableHero: false,
document: state.document,
fit: BoxFit.cover,
),
), ),
), Positioned.fill(
); child: DecoratedBox(
}, decoration: BoxDecoration(
), gradient: LinearGradient(
// Positioned.fill( stops: [0.2, 0.4],
// top: -kToolbarHeight, colors: [
// child: DecoratedBox( Theme.of(context)
// decoration: BoxDecoration( .colorScheme
// gradient: LinearGradient( .background
// colors: [ .withOpacity(0.6),
// Theme.of(context) Theme.of(context)
// .colorScheme .colorScheme
// .background .background
// .withOpacity(0.8), .withOpacity(0.3),
// Theme.of(context) ],
// .colorScheme begin: Alignment.topCenter,
// .background end: Alignment.bottomCenter,
// .withOpacity(0.5), ),
// Colors.transparent, ),
// Colors.transparent, ),
// Colors.transparent, ),
// ], ],
// begin: Alignment.topCenter, ),
// end: Alignment.bottomCenter, ),
// ), );
// ), },
// ),
// ),
],
), ),
), ),
bottom: ColoredTabBar( bottom: ColoredTabBar(
@@ -177,7 +177,7 @@ class _DocumentDetailsPageState extends State<DocumentDetailsPage> {
), ),
), ),
), ),
if (hasMultiUserSupport) if (hasMultiUserSupport && false)
Tab( Tab(
child: Text( child: Text(
"Permissions", "Permissions",
@@ -266,7 +266,7 @@ class _DocumentDetailsPageState extends State<DocumentDetailsPage> {
), ),
], ],
), ),
if (hasMultiUserSupport) if (hasMultiUserSupport && false)
CustomScrollView( CustomScrollView(
controller: _pagingScrollController, controller: _pagingScrollController,
slivers: [ slivers: [
@@ -406,7 +406,7 @@ class _DocumentDetailsPageState extends State<DocumentDetailsPage> {
if (delete) { if (delete) {
try { try {
await context.read<DocumentDetailsCubit>().delete(document); await context.read<DocumentDetailsCubit>().delete(document);
showSnackBar(context, S.of(context)!.documentSuccessfullyDeleted); // showSnackBar(context, S.of(context)!.documentSuccessfullyDeleted);
} on PaperlessApiException catch (error, stackTrace) { } on PaperlessApiException catch (error, stackTrace) {
showErrorMessage(context, error, stackTrace); showErrorMessage(context, error, stackTrace);
} finally { } finally {

View File

@@ -40,7 +40,6 @@ class _DocumentEditPageState extends State<DocumentEditPage> {
static const fkContent = 'content'; static const fkContent = 'content';
final GlobalKey<FormBuilderState> _formKey = GlobalKey(); final GlobalKey<FormBuilderState> _formKey = GlobalKey();
bool _isSubmitLoading = false;
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
@@ -314,18 +313,13 @@ class _DocumentEditPageState extends State<DocumentEditPage> {
tags: (values[fkTags] as IdsTagsQuery?)?.include, tags: (values[fkTags] as IdsTagsQuery?)?.include,
content: values[fkContent], content: values[fkContent],
); );
setState(() {
_isSubmitLoading = true;
});
try { try {
await context.read<DocumentEditCubit>().updateDocument(mergedDocument); await context.read<DocumentEditCubit>().updateDocument(mergedDocument);
showSnackBar(context, S.of(context)!.documentSuccessfullyUpdated); showSnackBar(context, S.of(context)!.documentSuccessfullyUpdated);
} on PaperlessApiException catch (error, stackTrace) { } on PaperlessApiException catch (error, stackTrace) {
showErrorMessage(context, error, stackTrace); showErrorMessage(context, error, stackTrace);
} finally { } finally {
setState(() {
_isSubmitLoading = false;
});
context.pop(); context.pop();
} }
} }

View File

@@ -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<File> 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));
}

View File

@@ -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/config/hive/hive_config.dart';
import 'package:paperless_mobile/core/database/tables/global_settings.dart'; import 'package:paperless_mobile/core/database/tables/global_settings.dart';
import 'package:paperless_mobile/core/global/constants.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/core/service/file_service.dart';
import 'package:paperless_mobile/features/app_drawer/view/app_drawer.dart'; import 'package:paperless_mobile/features/app_drawer/view/app_drawer.dart';
import 'package:paperless_mobile/features/document_scan/cubit/document_scanner_cubit.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_search/view/sliver_search_bar.dart';
import 'package:paperless_mobile/features/document_upload/view/document_upload_preparation_page.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/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/generated/l10n/app_localizations.dart';
import 'package:paperless_mobile/helpers/connectivity_aware_action_wrapper.dart'; import 'package:paperless_mobile/helpers/connectivity_aware_action_wrapper.dart';
import 'package:paperless_mobile/helpers/message_helpers.dart'; import 'package:paperless_mobile/helpers/message_helpers.dart';
@@ -261,12 +259,12 @@ class _ScannerPageState extends State<ScannerPage>
$extra: file.bytes, $extra: file.bytes,
fileExtension: file.extension, fileExtension: file.extension,
).push<DocumentUploadResult>(context); ).push<DocumentUploadResult>(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! // For paperless version older than 1.11.3, task id will always be null!
context.read<DocumentScannerCubit>().reset(); context.read<DocumentScannerCubit>().reset();
context // context
.read<PendingTasksNotifier>() // .read<PendingTasksNotifier>()
.listenToTaskChanges(uploadResult!.taskId!); // .listenToTaskChanges(uploadResult!.taskId!);
} }
} }
@@ -350,17 +348,17 @@ class _ScannerPageState extends State<ScannerPage>
void _onUploadFromFilesystem() async { void _onUploadFromFilesystem() async {
FilePickerResult? result = await FilePicker.platform.pickFiles( FilePickerResult? result = await FilePicker.platform.pickFiles(
type: FileType.custom, type: FileType.custom,
allowedExtensions: supportedFileExtensions, allowedExtensions:
supportedFileExtensions.map((e) => e.replaceAll(".", "")).toList(),
withData: true, withData: true,
allowMultiple: false, allowMultiple: false,
); );
if (result?.files.single.path != null) { if (result?.files.single.path != null) {
final path = result!.files.single.path!; 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); File file = File(path);
if (!supportedFileExtensions.contains( if (!supportedFileExtensions.contains(extension.toLowerCase())) {
fileDescription.extension.toLowerCase(),
)) {
showErrorMessage( showErrorMessage(
context, context,
const PaperlessApiException(ErrorCode.unsupportedFileFormat), const PaperlessApiException(ErrorCode.unsupportedFileFormat),
@@ -369,10 +367,15 @@ class _ScannerPageState extends State<ScannerPage>
} }
DocumentUploadRoute( DocumentUploadRoute(
$extra: file.readAsBytesSync(), $extra: file.readAsBytesSync(),
filename: fileDescription.filename, filename: filename,
title: fileDescription.filename, title: filename,
fileExtension: fileDescription.extension, fileExtension: extension,
).push(context); ).push<DocumentUploadResult>(context);
// if (uploadResult.success && uploadResult.taskId != null) {
// context
// .read<PendingTasksNotifier>()
// .listenToTaskChanges(uploadResult.taskId!);
// }
} }
} }

View File

@@ -16,6 +16,8 @@ class DocumentSearchCubit extends Cubit<DocumentSearchState>
with DocumentPagingBlocMixin { with DocumentPagingBlocMixin {
@override @override
final PaperlessDocumentsApi api; final PaperlessDocumentsApi api;
@override
final ConnectivityStatusService connectivityStatusService;
@override @override
final DocumentChangedNotifier notifier; final DocumentChangedNotifier notifier;
@@ -25,6 +27,7 @@ class DocumentSearchCubit extends Cubit<DocumentSearchState>
this.api, this.api,
this.notifier, this.notifier,
this._userAppState, this._userAppState,
this.connectivityStatusService,
) : super( ) : super(
DocumentSearchState( DocumentSearchState(
searchHistory: _userAppState.documentSearchHistory), searchHistory: _userAppState.documentSearchHistory),
@@ -120,9 +123,4 @@ class DocumentSearchCubit extends Cubit<DocumentSearchState>
@override @override
Future<void> onFilterUpdated(DocumentFilter filter) async {} Future<void> onFilterUpdated(DocumentFilter filter) async {}
@override
// TODO: implement connectivityStatusService
ConnectivityStatusService get connectivityStatusService =>
throw UnimplementedError();
} }

View File

@@ -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/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/manage_accounts_page.dart';
import 'package:paperless_mobile/features/settings/view/widgets/user_avatar.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:paperless_mobile/generated/l10n/app_localizations.dart';
import 'package:provider/provider.dart'; import 'package:provider/provider.dart';
@@ -51,7 +53,21 @@ class _DocumentSearchBarState extends State<DocumentSearchBar> {
crossAxisAlignment: CrossAxisAlignment.center, crossAxisAlignment: CrossAxisAlignment.center,
children: [ children: [
IconButton( IconButton(
icon: const Icon(Icons.menu), icon: ListenableBuilder(
listenable:
context.read<ConsumptionChangeNotifier>(),
builder: (context, child) {
return Badge(
isLabelVisible: context
.read<ConsumptionChangeNotifier>()
.pendingFiles
.isNotEmpty,
child: const Icon(Icons.menu),
backgroundColor: Colors.red,
smallSize: 8,
);
},
),
onPressed: Scaffold.of(context).openDrawer, onPressed: Scaffold.of(context).openDrawer,
), ),
Flexible( Flexible(
@@ -81,6 +97,7 @@ class _DocumentSearchBarState extends State<DocumentSearchBar> {
context.read(), context.read(),
Hive.box<LocalUserAppState>(HiveBoxes.localUserAppState) Hive.box<LocalUserAppState>(HiveBoxes.localUserAppState)
.get(context.read<LocalUserAccount>().id)!, .get(context.read<LocalUserAccount>().id)!,
context.read(),
), ),
child: const DocumentSearchPage(), child: const DocumentSearchPage(),
); );
@@ -95,10 +112,7 @@ class _DocumentSearchBarState extends State<DocumentSearchBar> {
onPressed: () { onPressed: () {
showDialog( showDialog(
context: context, context: context,
builder: (_) => Provider.value( builder: (_) => const ManageAccountsPage(),
value: context.read<LocalUserAccount>(),
child: const ManageAccountsPage(),
),
); );
}, },
); );

View File

@@ -1,5 +1,4 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:hive_flutter/adapters.dart'; import 'package:hive_flutter/adapters.dart';
import 'package:paperless_api/paperless_api.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_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/global_settings_builder.dart';
import 'package:paperless_mobile/features/settings/view/widgets/user_avatar.dart'; import 'package:paperless_mobile/features/settings/view/widgets/user_avatar.dart';
import 'package:provider/provider.dart'; import 'package:provider/provider.dart';
import 'package:sliver_tools/sliver_tools.dart';
class SliverSearchBar extends StatelessWidget { class SliverSearchBar extends StatelessWidget {
final bool floating; final bool floating;
@@ -24,10 +22,8 @@ class SliverSearchBar extends StatelessWidget {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final theme = Theme.of(context);
if (context.watch<LocalUserAccount>().paperlessUser.canViewDocuments) { if (context.watch<LocalUserAccount>().paperlessUser.canViewDocuments) {
return SliverAppBar( return const SliverAppBar(
titleSpacing: 8, titleSpacing: 8,
automaticallyImplyLeading: false, automaticallyImplyLeading: false,
title: DocumentSearchBar(), title: DocumentSearchBar(),

View File

@@ -6,12 +6,13 @@ import 'package:flutter/foundation.dart';
import 'package:paperless_api/paperless_api.dart'; import 'package:paperless_api/paperless_api.dart';
import 'package:paperless_mobile/core/repository/label_repository.dart'; import 'package:paperless_mobile/core/repository/label_repository.dart';
import 'package:paperless_mobile/core/service/connectivity_status_service.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'; part 'document_upload_state.dart';
class DocumentUploadCubit extends Cubit<DocumentUploadState> { class DocumentUploadCubit extends Cubit<DocumentUploadState> {
final PaperlessDocumentsApi _documentApi; final PaperlessDocumentsApi _documentApi;
final PendingTasksNotifier _tasksNotifier;
final LabelRepository _labelRepository; final LabelRepository _labelRepository;
final ConnectivityStatusService _connectivityStatusService; final ConnectivityStatusService _connectivityStatusService;
@@ -19,6 +20,7 @@ class DocumentUploadCubit extends Cubit<DocumentUploadState> {
this._labelRepository, this._labelRepository,
this._documentApi, this._documentApi,
this._connectivityStatusService, this._connectivityStatusService,
this._tasksNotifier,
) : super(const DocumentUploadState()) { ) : super(const DocumentUploadState()) {
_labelRepository.addListener( _labelRepository.addListener(
this, this,
@@ -43,7 +45,7 @@ class DocumentUploadCubit extends Cubit<DocumentUploadState> {
DateTime? createdAt, DateTime? createdAt,
int? asn, int? asn,
}) async { }) async {
return await _documentApi.create( final taskId = await _documentApi.create(
bytes, bytes,
filename: filename, filename: filename,
title: title, title: title,
@@ -53,6 +55,10 @@ class DocumentUploadCubit extends Cubit<DocumentUploadState> {
createdAt: createdAt, createdAt: createdAt,
asn: asn, asn: asn,
); );
if (taskId != null) {
_tasksNotifier.listenToTaskChanges(taskId);
}
return taskId;
} }
@override @override

View File

@@ -6,7 +6,6 @@ import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:flutter_form_builder/flutter_form_builder.dart'; import 'package:flutter_form_builder/flutter_form_builder.dart';
import 'package:go_router/go_router.dart'; import 'package:go_router/go_router.dart';
import 'package:hive/hive.dart'; import 'package:hive/hive.dart';
import 'package:image/image.dart' as img;
import 'package:intl/date_symbol_data_local.dart'; import 'package:intl/date_symbol_data_local.dart';
import 'package:intl/intl.dart'; import 'package:intl/intl.dart';
import 'package:paperless_api/paperless_api.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/labels/view/widgets/label_form_field.dart';
import 'package:paperless_mobile/features/sharing/view/widgets/file_thumbnail.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/generated/l10n/app_localizations.dart';
import 'package:paperless_mobile/helpers/message_helpers.dart'; import 'package:paperless_mobile/helpers/message_helpers.dart';
import 'package:provider/provider.dart'; import 'package:provider/provider.dart';

View File

@@ -247,8 +247,13 @@ class _DocumentsPageState extends State<DocumentsPage> {
resizeToAvoidBottomInset: true, resizeToAvoidBottomInset: true,
body: WillPopScope( body: WillPopScope(
onWillPop: () async { onWillPop: () async {
if (context.read<DocumentsCubit>().state.selection.isNotEmpty) { final cubit = context.read<DocumentsCubit>();
context.read<DocumentsCubit>().resetSelection(); 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 false;
} }
return true; return true;

View File

@@ -31,16 +31,19 @@ class DocumentPreview extends StatelessWidget {
Widget build(BuildContext context) { Widget build(BuildContext context) {
return ConnectivityAwareActionWrapper( return ConnectivityAwareActionWrapper(
child: GestureDetector( child: GestureDetector(
behavior: HitTestBehavior.translucent,
onTap: isClickable onTap: isClickable
? () => DocumentPreviewRoute($extra: document).push(context) ? () => DocumentPreviewRoute($extra: document).push(context)
: null, : null,
child: HeroMode( child: Builder(builder: (context) {
enabled: enableHero, if (enableHero) {
child: Hero( return Hero(
tag: "thumb_${document.id}", tag: "thumb_${document.id}",
child: _buildPreview(context), child: _buildPreview(context),
), );
), }
return _buildPreview(context);
}),
), ),
); );
} }

View File

@@ -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());
}
}

View File

@@ -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/pages/documents_page.dart';
import 'package:paperless_mobile/features/documents/view/widgets/search/document_filter_form.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/generated/l10n/app_localizations.dart';
import 'package:paperless_mobile/helpers/connectivity_aware_action_wrapper.dart';
enum DateRangeSelection { before, after } enum DateRangeSelection { before, after }

View File

@@ -1,10 +1,9 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:flutter_cache_manager/flutter_cache_manager.dart'; import 'package:flutter_cache_manager/flutter_cache_manager.dart';
import 'package:hive_flutter/adapters.dart'; import 'package:hive_flutter/adapters.dart';
import 'package:paperless_api/paperless_api.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_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/database/tables/local_user_app_state.dart';
import 'package:paperless_mobile/core/factory/paperless_api_factory.dart'; import 'package:paperless_mobile/core/factory/paperless_api_factory.dart';
import 'package:paperless_mobile/core/repository/label_repository.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/home/view/model/api_version.dart';
import 'package:paperless_mobile/features/inbox/cubit/inbox_cubit.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/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/saved_view/cubit/saved_view_cubit.dart';
import 'package:paperless_mobile/features/settings/view/widgets/global_settings_builder.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/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'; import 'package:provider/provider.dart';
class HomeShellWidget extends StatelessWidget { class HomeShellWidget extends StatelessWidget {
@@ -52,16 +46,16 @@ class HomeShellWidget extends StatelessWidget {
return GlobalSettingsBuilder( return GlobalSettingsBuilder(
builder: (context, settings) { builder: (context, settings) {
final currentUserId = settings.loggedInUserId; 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); final apiVersion = ApiVersion(paperlessApiVersion);
return ValueListenableBuilder( return ValueListenableBuilder(
valueListenable: valueListenable:
Hive.box<LocalUserAccount>(HiveBoxes.localUserAccount) Hive.localUserAccountBox.listenable(keys: [currentUserId]),
.listenable(keys: [currentUserId]),
builder: (context, box, _) { 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)!; final currentLocalUser = box.get(currentUserId)!;
return MultiProvider( return MultiProvider(
key: ValueKey(currentUserId), key: ValueKey(currentUserId),
@@ -181,9 +175,7 @@ class HomeShellWidget extends StatelessWidget {
context.read(), context.read(),
context.read(), context.read(),
); );
if (currentLocalUser if (currentLocalUser.paperlessUser.canViewInbox) {
.paperlessUser.canViewDocuments &&
currentLocalUser.paperlessUser.canViewTags) {
inboxCubit.initialize(); inboxCubit.initialize();
} }
return inboxCubit; return inboxCubit;

View File

@@ -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,
);
}
}

View File

@@ -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/generated/l10n/app_localizations.dart';
import 'package:paperless_mobile/theme.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 { class ScaffoldWithNavigationBar extends StatefulWidget {
final UserModel authenticatedUser; final UserModel authenticatedUser;
final StatefulNavigationShell navigationShell; final StatefulNavigationShell navigationShell;
@@ -29,11 +23,6 @@ class ScaffoldWithNavigationBar extends StatefulWidget {
} }
class ScaffoldWithNavigationBarState extends State<ScaffoldWithNavigationBar> { class ScaffoldWithNavigationBarState extends State<ScaffoldWithNavigationBar> {
@override
void didChangeDependencies() {
super.didChangeDependencies();
}
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final theme = Theme.of(context); final theme = Theme.of(context);
@@ -58,7 +47,7 @@ class ScaffoldWithNavigationBarState extends State<ScaffoldWithNavigationBar> {
Icons.home, Icons.home,
color: theme.colorScheme.primary, color: theme.colorScheme.primary,
), ),
label: S.of(context)!.home, label: S.of(context)!.home,
), ),
_toggleDestination( _toggleDestination(
NavigationDestination( NavigationDestination(

View File

@@ -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<AuthenticationCubit>()
.restoreSessionState(),
child: Text(S.of(context)!.verifyIdentity),
),
],
).padded(16),
],
);
},
),
),
);
}
void _logout(BuildContext context) {
context.read<AuthenticationCubit>().logout();
context.read<LabelRepository>().clear();
context.read<SavedViewRepository>().clear();
HydratedBloc.storage.clear();
}
}

View File

@@ -19,8 +19,10 @@ class InboxCubit extends HydratedCubit<InboxState>
final LabelRepository _labelRepository; final LabelRepository _labelRepository;
final PaperlessDocumentsApi _documentsApi; final PaperlessDocumentsApi _documentsApi;
@override @override
final ConnectivityStatusService connectivityStatusService; final ConnectivityStatusService connectivityStatusService;
@override @override
final DocumentChangedNotifier notifier; final DocumentChangedNotifier notifier;
@@ -35,21 +37,34 @@ class InboxCubit extends HydratedCubit<InboxState>
this._labelRepository, this._labelRepository,
this.notifier, this.notifier,
this.connectivityStatusService, this.connectivityStatusService,
) : super(InboxState( ) : super(InboxState(labels: _labelRepository.state)) {
labels: _labelRepository.state,
)) {
notifier.addListener( notifier.addListener(
this, this,
onDeleted: remove, onDeleted: remove,
onUpdated: (document) { onUpdated: (document) {
if (document.tags final hasInboxTag = document.tags
.toSet() .toSet()
.intersection(state.inboxTags.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); remove(document);
emit(state.copyWith(itemsInInboxCount: state.itemsInInboxCount - 1)); emit(state.copyWith(itemsInInboxCount: state.itemsInInboxCount - 1));
} else { } else if (hasInboxTag) {
replace(document); 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<InboxState>
); );
} }
@override
Future<void> initialize() async { Future<void> initialize() async {
await refreshItemsInInboxCount(false); await refreshItemsInInboxCount(false);
await loadInbox(); await loadInbox();
} }
Future<void> refreshItemsInInboxCount([bool shouldLoadInbox = true]) async { Future<void> refreshItemsInInboxCount([bool shouldLoadInbox = true]) async {
debugPrint("Checking for new items in inbox...");
final stats = await _statsApi.getServerStatistics(); final stats = await _statsApi.getServerStatistics();
if (stats.documentsInInbox != state.itemsInInboxCount && shouldLoadInbox) { if (stats.documentsInInbox != state.itemsInInboxCount && shouldLoadInbox) {
await loadInbox(); await loadInbox();
} }
emit( emit(state.copyWith(itemsInInboxCount: stats.documentsInInbox));
state.copyWith(
itemsInInboxCount: stats.documentsInInbox,
),
);
} }
/// ///
@@ -85,7 +98,6 @@ class InboxCubit extends HydratedCubit<InboxState>
Future<void> loadInbox() async { Future<void> loadInbox() async {
if (!isClosed) { if (!isClosed) {
debugPrint("Initializing inbox..."); debugPrint("Initializing inbox...");
final inboxTags = await _labelRepository.findAllTags().then( final inboxTags = await _labelRepository.findAllTags().then(
(tags) => tags.where((t) => t.isInboxTag).map((t) => t.id!), (tags) => tags.where((t) => t.isInboxTag).map((t) => t.id!),
); );
@@ -113,11 +125,22 @@ class InboxCubit extends HydratedCubit<InboxState>
} }
} }
Future<void> _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). /// Fetches inbox tag ids and loads the inbox items (documents).
/// ///
Future<void> reloadInbox() async { Future<void> reloadInbox() async {
emit(state.copyWith(hasLoaded: false, isLoading: true));
final inboxTags = await _labelRepository.findAllTags().then( final inboxTags = await _labelRepository.findAllTags().then(
(tags) => tags.where((t) => t.isInboxTag).map((t) => t.id!), (tags) => tags.where((t) => t.isInboxTag).map((t) => t.id!),
); );
@@ -134,6 +157,7 @@ class InboxCubit extends HydratedCubit<InboxState>
} }
emit(state.copyWith(inboxTags: inboxTags)); emit(state.copyWith(inboxTags: inboxTags));
updateFilter( updateFilter(
emitLoading: false,
filter: DocumentFilter( filter: DocumentFilter(
sortField: SortField.added, sortField: SortField.added,
tags: TagsQuery.ids(include: inboxTags.toList()), tags: TagsQuery.ids(include: inboxTags.toList()),
@@ -154,7 +178,7 @@ class InboxCubit extends HydratedCubit<InboxState>
document.copyWith(tags: updatedTags), document.copyWith(tags: updatedTags),
); );
// Remove first so document is not replaced first. // Remove first so document is not replaced first.
remove(document); // remove(document);
notifier.notifyUpdated(updatedDocument); notifier.notifyUpdated(updatedDocument);
return tagsToRemove; return tagsToRemove;
} }

View File

@@ -42,6 +42,7 @@ class _InboxPageState extends State<InboxPage>
@override @override
void initState() { void initState() {
super.initState(); super.initState();
context.read<InboxCubit>().reloadInbox();
WidgetsBinding.instance.addPostFrameCallback((_) { WidgetsBinding.instance.addPostFrameCallback((_) {
_nestedScrollViewKey.currentState!.innerController _nestedScrollViewKey.currentState!.innerController
.addListener(_scrollExtentChangedListener); .addListener(_scrollExtentChangedListener);

View File

@@ -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,
),
),
),
);
}
}

View File

@@ -1,5 +1,6 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:paperless_api/paperless_api.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/features/edit_label/view/impl/add_tag_page.dart';
import 'package:paperless_mobile/generated/l10n/app_localizations.dart'; import 'package:paperless_mobile/generated/l10n/app_localizations.dart';
@@ -68,9 +69,10 @@ class _FullscreenTagsFormState extends State<FullscreenTagsForm> {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final showFab = MediaQuery.viewInsetsOf(context).bottom == 0;
final theme = Theme.of(context); final theme = Theme.of(context);
return Scaffold( return Scaffold(
floatingActionButton: widget.allowCreation floatingActionButton: widget.allowCreation && showFab
? FloatingActionButton( ? FloatingActionButton(
heroTag: "fab_tags_form", heroTag: "fab_tags_form",
onPressed: _onAddTag, onPressed: _onAddTag,
@@ -238,10 +240,16 @@ class _FullscreenTagsFormState extends State<FullscreenTagsForm> {
var matches = _options var matches = _options
.where((e) => e.name.trim().toLowerCase().contains(normalizedQuery)); .where((e) => e.name.trim().toLowerCase().contains(normalizedQuery));
if (matches.isEmpty && widget.allowCreation) { if (matches.isEmpty && widget.allowCreation) {
yield Text(S.of(context)!.noItemsFound); yield Center(
yield TextButton( child: Column(
child: Text(S.of(context)!.addTag), children: [
onPressed: _onAddTag, Text(S.of(context)!.noItemsFound).padded(),
TextButton(
child: Text(S.of(context)!.addTag),
onPressed: _onAddTag,
),
],
),
); );
} }
for (final tag in matches) { for (final tag in matches) {

View File

@@ -69,6 +69,7 @@ class _FullscreenLabelFormState<T extends Label>
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final showFab = MediaQuery.viewInsetsOf(context).bottom == 0;
final theme = Theme.of(context); final theme = Theme.of(context);
final options = _filterOptionsByQuery(_textEditingController.text); final options = _filterOptionsByQuery(_textEditingController.text);
return Scaffold( return Scaffold(
@@ -124,6 +125,13 @@ class _FullscreenLabelFormState<T extends Label>
), ),
), ),
), ),
floatingActionButton: showFab && widget.onCreateNewLabel != null
? FloatingActionButton(
heroTag: "fab_label_form",
onPressed: _onCreateNewLabel,
child: const Icon(Icons.add),
)
: null,
body: Builder( body: Builder(
builder: (context) { builder: (context) {
return Column( return Column(

View File

@@ -2,7 +2,6 @@ import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:paperless_api/paperless_api.dart'; import 'package:paperless_api/paperless_api.dart';
import 'package:paperless_mobile/core/database/tables/local_user_account.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/helpers/format_helpers.dart';
import 'package:paperless_mobile/routes/typed/branches/labels_route.dart'; import 'package:paperless_mobile/routes/typed/branches/labels_route.dart';

View File

@@ -76,9 +76,7 @@ class LabelTabView<T extends Label> extends StatelessWidget {
Text( Text(
translateMatchingAlgorithmName( translateMatchingAlgorithmName(
context, l.matchingAlgorithm) + context, l.matchingAlgorithm) +
((l.match?.isNotEmpty ?? false) (l.match.isNotEmpty ? ": ${l.match}" : ""),
? ": ${l.match}"
: ""),
maxLines: 2, maxLines: 2,
), ),
onOpenEditPage: canEdit ? onEdit : null, onOpenEditPage: canEdit ? onEdit : null,

View File

@@ -146,7 +146,7 @@ class _LandingPageState extends State<LandingPage> {
shape: Theme.of(context).cardTheme.shape, shape: Theme.of(context).cardTheme.shape,
titleTextStyle: Theme.of(context).textTheme.labelLarge, titleTextStyle: Theme.of(context).textTheme.labelLarge,
title: Text(S.of(context)!.documentsInInbox), title: Text(S.of(context)!.documentsInInbox),
onTap: currentUser.canViewTags && currentUser.canViewDocuments onTap: currentUser.canViewInbox
? () => InboxRoute().go(context) ? () => InboxRoute().go(context)
: null, : null,
trailing: Text( trailing: Text(
@@ -161,9 +161,11 @@ class _LandingPageState extends State<LandingPage> {
shape: Theme.of(context).cardTheme.shape, shape: Theme.of(context).cardTheme.shape,
titleTextStyle: Theme.of(context).textTheme.labelLarge, titleTextStyle: Theme.of(context).textTheme.labelLarge,
title: Text(S.of(context)!.totalDocuments), title: Text(S.of(context)!.totalDocuments),
onTap: () { onTap: currentUser.canViewDocuments
DocumentsRoute().go(context); ? () {
}, DocumentsRoute().go(context);
}
: null,
trailing: Text( trailing: Text(
stats.documentsTotal.toString(), stats.documentsTotal.toString(),
style: Theme.of(context).textTheme.labelLarge, style: Theme.of(context).textTheme.labelLarge,

View File

@@ -1,5 +1,3 @@
import 'dart:math';
import 'package:fl_chart/fl_chart.dart'; import 'package:fl_chart/fl_chart.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:paperless_api/paperless_api.dart'; import 'package:paperless_api/paperless_api.dart';

View File

@@ -1,8 +1,6 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.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/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/adaptive_documents_view.dart';
import 'package:paperless_mobile/features/documents/view/widgets/selection/view_type_selection_widget.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'; import 'package:paperless_mobile/features/linked_documents/cubit/linked_documents_cubit.dart';

View File

@@ -1,11 +1,9 @@
import 'package:connectivity_plus/connectivity_plus.dart';
import 'package:dio/dio.dart'; import 'package:dio/dio.dart';
import 'package:equatable/equatable.dart';
import 'package:flutter/widgets.dart'; import 'package:flutter/widgets.dart';
import 'package:freezed_annotation/freezed_annotation.dart';
import 'package:hive_flutter/adapters.dart'; import 'package:hive_flutter/adapters.dart';
import 'package:hydrated_bloc/hydrated_bloc.dart'; import 'package:hydrated_bloc/hydrated_bloc.dart';
import 'package:paperless_api/paperless_api.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_config.dart';
import 'package:paperless_mobile/core/config/hive/hive_extensions.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/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/local_user_settings.dart';
import 'package:paperless_mobile/core/database/tables/user_credentials.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/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/model/info_message_exception.dart';
import 'package:paperless_mobile/core/security/session_manager.dart'; import 'package:paperless_mobile/core/security/session_manager.dart';
import 'package:paperless_mobile/core/service/connectivity_status_service.dart'; import 'package:paperless_mobile/core/service/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/login_form_credentials.dart';
import 'package:paperless_mobile/features/login/model/reachability_status.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/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'; import 'package:paperless_mobile/generated/l10n/app_localizations.dart';
part 'authentication_state.dart'; part 'authentication_state.dart';
typedef _FutureVoidCallback = Future<void> Function();
class AuthenticationCubit extends Cubit<AuthenticationState> { class AuthenticationCubit extends Cubit<AuthenticationState> {
final LocalAuthenticationService _localAuthService; final LocalAuthenticationService _localAuthService;
final PaperlessApiFactory _apiFactory; final PaperlessApiFactory _apiFactory;
final SessionManager _sessionManager; final SessionManager _sessionManager;
final ConnectivityStatusService _connectivityService; final ConnectivityStatusService _connectivityService;
final LocalNotificationService _notificationService;
AuthenticationCubit( AuthenticationCubit(
this._localAuthService, this._localAuthService,
this._apiFactory, this._apiFactory,
this._sessionManager, this._sessionManager,
this._connectivityService, this._connectivityService,
this._notificationService,
) : super(const UnauthenticatedState()); ) : super(const UnauthenticatedState());
Future<void> login({ Future<void> login({
@@ -45,7 +49,11 @@ class AuthenticationCubit extends Cubit<AuthenticationState> {
ClientCertificate? clientCertificate, ClientCertificate? clientCertificate,
}) async { }) async {
assert(credentials.username != null && credentials.password != null); 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"; final localUserId = "${credentials.username}@$serverUrl";
_debugPrintMessage( _debugPrintMessage(
"login", "login",
@@ -58,35 +66,63 @@ class AuthenticationCubit extends Cubit<AuthenticationState> {
credentials, credentials,
clientCertificate, clientCertificate,
_sessionManager, _sessionManager,
onFetchUserInformation: () async {
emit(const AuthenticatingState(
AuthenticatingStage.fetchingUserInformation));
},
onPerformLogin: () async {
emit(const AuthenticatingState(AuthenticatingStage.authenticating));
},
onPersistLocalUserData: () async {
emit(const AuthenticatingState(
AuthenticatingStage.persistingLocalUserData));
},
); );
} catch (e) {
// Mark logged in user as currently active user. emit(
final globalSettings = AuthenticationErrorState(
Hive.box<GlobalSettings>(HiveBoxes.globalSettings).getValue()!; serverUrl: serverUrl,
globalSettings.loggedInUserId = localUserId; username: credentials.username!,
await globalSettings.save(); password: credentials.password!,
clientCertificate: clientCertificate,
emit(AuthenticatedState(localUserId: localUserId)); ),
_debugPrintMessage(
"login",
"User successfully logged in.",
); );
} catch (error) { rethrow;
emit(const UnauthenticatedState());
} }
// Mark logged in user as currently active user.
final globalSettings =
Hive.box<GlobalSettings>(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. /// Switches to another account if it exists.
Future<void> switchAccount(String localUserId) async { Future<void> switchAccount(String localUserId) async {
emit(const SwitchingAccountsState()); emit(const SwitchingAccountsState());
_debugPrintMessage(
"switchAccount",
"Trying to switch to user $localUserId...",
);
final globalSettings = final globalSettings =
Hive.box<GlobalSettings>(HiveBoxes.globalSettings).getValue()!; Hive.box<GlobalSettings>(HiveBoxes.globalSettings).getValue()!;
if (globalSettings.loggedInUserId == localUserId) { // if (globalSettings.loggedInUserId == localUserId) {
emit(AuthenticatedState(localUserId: localUserId)); // _debugPrintMessage(
return; // "switchAccount",
} // "User $localUserId is already logged in.",
final userAccountBox = // );
Hive.box<LocalUserAccount>(HiveBoxes.localUserAccount); // emit(AuthenticatedState(localUserId: localUserId));
// return;
// }
final userAccountBox = Hive.localUserAccountBox;
if (!userAccountBox.containsKey(localUserId)) { if (!userAccountBox.containsKey(localUserId)) {
debugPrint("User $localUserId not yet registered."); debugPrint("User $localUserId not yet registered.");
@@ -99,10 +135,18 @@ class AuthenticationCubit extends Cubit<AuthenticationState> {
final authenticated = await _localAuthService final authenticated = await _localAuthService
.authenticateLocalUser("Authenticate to switch your account."); .authenticateLocalUser("Authenticate to switch your account.");
if (!authenticated) { if (!authenticated) {
debugPrint("User not authenticated."); _debugPrintMessage(
"switchAccount",
"User could not be authenticated.",
);
emit(VerifyIdentityState(userId: localUserId));
return; return;
} }
} }
final currentlyLoggedInUser = globalSettings.loggedInUserId;
if (currentlyLoggedInUser != localUserId) {
await _notificationService.cancelUserNotifications(localUserId);
}
await withEncryptedBox<UserCredentials, void>( await withEncryptedBox<UserCredentials, void>(
HiveBoxes.localUserCredentials, (credentialsBox) async { HiveBoxes.localUserCredentials, (credentialsBox) async {
if (!credentialsBox.containsKey(localUserId)) { if (!credentialsBox.containsKey(localUserId)) {
@@ -131,9 +175,7 @@ class AuthenticationCubit extends Cubit<AuthenticationState> {
apiVersion, apiVersion,
); );
emit(AuthenticatedState( emit(AuthenticatedState(localUserId: localUserId));
localUserId: localUserId,
));
}); });
} }
@@ -142,19 +184,33 @@ class AuthenticationCubit extends Cubit<AuthenticationState> {
required String serverUrl, required String serverUrl,
ClientCertificate? clientCertificate, ClientCertificate? clientCertificate,
required bool enableBiometricAuthentication, required bool enableBiometricAuthentication,
required String locale,
}) async { }) async {
assert(credentials.password != null && credentials.username != null); assert(credentials.password != null && credentials.username != null);
final localUserId = "${credentials.username}@$serverUrl"; 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<void> removeAccount(String userId) async { Future<void> removeAccount(String userId) async {
@@ -170,28 +226,33 @@ class AuthenticationCubit extends Cubit<AuthenticationState> {
} }
/// ///
/// Performs a conditional hydration based on the local authentication success. /// Restores the previous session if exists.
/// ///
Future<void> restoreSessionState() async { Future<void> restoreSession([String? userId]) async {
emit(const RestoringSessionState());
_debugPrintMessage( _debugPrintMessage(
"restoreSessionState", "restoreSessionState",
"Trying to restore previous session...", "Trying to restore previous session...",
); );
final globalSettings = final globalSettings =
Hive.box<GlobalSettings>(HiveBoxes.globalSettings).getValue()!; Hive.box<GlobalSettings>(HiveBoxes.globalSettings).getValue()!;
final localUserId = globalSettings.loggedInUserId; final restoreSessionForUser = userId ?? globalSettings.loggedInUserId;
if (localUserId == null) { // final localUserId = globalSettings.loggedInUserId;
if (restoreSessionForUser == null) {
_debugPrintMessage( _debugPrintMessage(
"restoreSessionState", "restoreSessionState",
"There is nothing to restore.", "There is nothing to restore.",
); );
final otherAccountsExist = Hive.localUserAccountBox.isNotEmpty;
// If there is nothing to restore, we can quit here. // If there is nothing to restore, we can quit here.
emit(const UnauthenticatedState()); emit(
UnauthenticatedState(redirectToAccountSelection: otherAccountsExist),
);
return; return;
} }
final localUserAccountBox = final localUserAccountBox =
Hive.box<LocalUserAccount>(HiveBoxes.localUserAccount); Hive.box<LocalUserAccount>(HiveBoxes.localUserAccount);
final localUserAccount = localUserAccountBox.get(localUserId)!; final localUserAccount = localUserAccountBox.get(restoreSessionForUser)!;
_debugPrintMessage( _debugPrintMessage(
"restoreSessionState", "restoreSessionState",
"Checking if biometric authentication is required...", "Checking if biometric authentication is required...",
@@ -207,7 +268,7 @@ class AuthenticationCubit extends Cubit<AuthenticationState> {
final localAuthSuccess = final localAuthSuccess =
await _localAuthService.authenticateLocalUser(authenticationMesage); await _localAuthService.authenticateLocalUser(authenticationMesage);
if (!localAuthSuccess) { if (!localAuthSuccess) {
emit(const RequiresLocalAuthenticationState()); emit(VerifyIdentityState(userId: restoreSessionForUser));
_debugPrintMessage( _debugPrintMessage(
"restoreSessionState", "restoreSessionState",
"User could not be authenticated.", "User could not be authenticated.",
@@ -231,7 +292,7 @@ class AuthenticationCubit extends Cubit<AuthenticationState> {
final authentication = final authentication =
await withEncryptedBox<UserCredentials, UserCredentials>( await withEncryptedBox<UserCredentials, UserCredentials>(
HiveBoxes.localUserCredentials, (box) { HiveBoxes.localUserCredentials, (box) {
return box.get(globalSettings.loggedInUserId!); return box.get(restoreSessionForUser);
}); });
if (authentication == null) { if (authentication == null) {
@@ -290,8 +351,9 @@ class AuthenticationCubit extends Cubit<AuthenticationState> {
"Skipping update of server user (server could not be reached).", "Skipping update of server user (server could not be reached).",
); );
} }
globalSettings.loggedInUserId = restoreSessionForUser;
emit(AuthenticatedState(localUserId: localUserId)); await globalSettings.save();
emit(AuthenticatedState(localUserId: restoreSessionForUser));
_debugPrintMessage( _debugPrintMessage(
"restoreSessionState", "restoreSessionState",
@@ -300,7 +362,7 @@ class AuthenticationCubit extends Cubit<AuthenticationState> {
} }
Future<void> logout([bool removeAccount = false]) async { Future<void> logout([bool removeAccount = false]) async {
emit(const LogginOutState()); emit(const LoggingOutState());
_debugPrintMessage( _debugPrintMessage(
"logout", "logout",
"Trying to log out current user...", "Trying to log out current user...",
@@ -308,13 +370,16 @@ class AuthenticationCubit extends Cubit<AuthenticationState> {
await _resetExternalState(); await _resetExternalState();
final globalSettings = Hive.globalSettingsBox.getValue()!; final globalSettings = Hive.globalSettingsBox.getValue()!;
final userId = globalSettings.loggedInUserId!; final userId = globalSettings.loggedInUserId!;
await _notificationService.cancelUserNotifications(userId);
final otherAccountsExist = Hive.localUserAccountBox.length > 1;
emit(UnauthenticatedState(redirectToAccountSelection: otherAccountsExist));
if (removeAccount) { if (removeAccount) {
this.removeAccount(userId); await this.removeAccount(userId);
} }
globalSettings.loggedInUserId = null; globalSettings.loggedInUserId = null;
await globalSettings.save(); await globalSettings.save();
emit(const UnauthenticatedState());
_debugPrintMessage( _debugPrintMessage(
"logout", "logout",
"User successfully logged out.", "User successfully logged out.",
@@ -322,16 +387,8 @@ class AuthenticationCubit extends Cubit<AuthenticationState> {
} }
Future<void> _resetExternalState() async { Future<void> _resetExternalState() async {
_debugPrintMessage(
"_resetExternalState",
"Resetting session manager and clearing storage...",
);
_sessionManager.resetSettings(); _sessionManager.resetSettings();
await HydratedBloc.storage.clear(); await HydratedBloc.storage.clear();
_debugPrintMessage(
"_resetExternalState",
"Session manager successfully reset and storage cleared.",
);
} }
Future<int> _addUser( Future<int> _addUser(
@@ -339,8 +396,11 @@ class AuthenticationCubit extends Cubit<AuthenticationState> {
String serverUrl, String serverUrl,
LoginFormCredentials credentials, LoginFormCredentials credentials,
ClientCertificate? clientCert, ClientCertificate? clientCert,
SessionManager sessionManager, SessionManager sessionManager, {
) async { _FutureVoidCallback? onPerformLogin,
_FutureVoidCallback? onPersistLocalUserData,
_FutureVoidCallback? onFetchUserInformation,
}) async {
assert(credentials.username != null && credentials.password != null); assert(credentials.username != null && credentials.password != null);
_debugPrintMessage("_addUser", "Adding new user $localUserId..."); _debugPrintMessage("_addUser", "Adding new user $localUserId...");
@@ -356,6 +416,8 @@ class AuthenticationCubit extends Cubit<AuthenticationState> {
"Trying to login user ${credentials.username} on $serverUrl...", "Trying to login user ${credentials.username} on $serverUrl...",
); );
await onPerformLogin?.call();
final token = await authApi.login( final token = await authApi.login(
username: credentials.username!, username: credentials.username!,
password: credentials.password!, password: credentials.password!,
@@ -384,6 +446,7 @@ class AuthenticationCubit extends Cubit<AuthenticationState> {
); );
throw InfoMessageException(code: ErrorCode.userAlreadyExists); throw InfoMessageException(code: ErrorCode.userAlreadyExists);
} }
await onFetchUserInformation?.call();
final apiVersion = await _getApiVersion(sessionManager.client); final apiVersion = await _getApiVersion(sessionManager.client);
_debugPrintMessage( _debugPrintMessage(
"_addUser", "_addUser",
@@ -413,6 +476,7 @@ class AuthenticationCubit extends Cubit<AuthenticationState> {
"_addUser", "_addUser",
"Persisting local user account...", "Persisting local user account...",
); );
await onPersistLocalUserData?.call();
// Create user account // Create user account
await userAccountBox.put( await userAccountBox.put(
localUserId, localUserId,
@@ -490,7 +554,7 @@ class AuthenticationCubit extends Cubit<AuthenticationState> {
"API version ($apiVersion) successfully retrieved.", "API version ($apiVersion) successfully retrieved.",
); );
return apiVersion; return apiVersion;
} on DioException catch (e) { } on DioException catch (_) {
return defaultValue; return defaultValue;
} }
} }

View File

@@ -7,34 +7,76 @@ sealed class AuthenticationState {
switch (this) { AuthenticatedState() => true, _ => false }; switch (this) { AuthenticatedState() => true, _ => false };
} }
class UnauthenticatedState extends AuthenticationState { class UnauthenticatedState extends AuthenticationState with EquatableMixin {
const UnauthenticatedState(); final bool redirectToAccountSelection;
const UnauthenticatedState({this.redirectToAccountSelection = false});
@override
List<Object?> get props => [redirectToAccountSelection];
} }
class RequiresLocalAuthenticationState extends AuthenticationState { class RestoringSessionState extends AuthenticationState {
const RequiresLocalAuthenticationState(); const RestoringSessionState();
} }
class CheckingLoginState extends AuthenticationState { class VerifyIdentityState extends AuthenticationState {
const CheckingLoginState(); final String userId;
const VerifyIdentityState({required this.userId});
} }
class LogginOutState extends AuthenticationState { class AuthenticatingState extends AuthenticationState with EquatableMixin {
const LogginOutState(); final AuthenticatingStage currentStage;
const AuthenticatingState(this.currentStage);
@override
List<Object?> get props => [currentStage];
} }
class AuthenticatedState extends AuthenticationState { class LoggingOutState extends AuthenticationState {
const LoggingOutState();
}
class AuthenticatedState extends AuthenticationState with EquatableMixin {
final String localUserId; final String localUserId;
const AuthenticatedState({ const AuthenticatedState({required this.localUserId});
required this.localUserId,
}); @override
List<Object?> get props => [localUserId];
} }
class SwitchingAccountsState extends AuthenticationState { class SwitchingAccountsState extends AuthenticationState {
const SwitchingAccountsState(); const SwitchingAccountsState();
} }
class AuthenticationErrorState extends AuthenticationState { class AuthenticationErrorState extends AuthenticationState with EquatableMixin {
const AuthenticationErrorState(); 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<Object?> get props => [
errorCode,
serverUrl,
clientCertificate,
username,
password,
];
}
enum AuthenticatingStage {
authenticating,
persistingLocalUserData,
fetchingUserInformation,
} }

View File

@@ -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<Object?> get props => [
localUserId,
username,
fullName,
isAuthenticated,
showBiometricAuthenticationScreen,
apiVersion,
];
}

View File

@@ -12,5 +12,9 @@ class ClientCertificate {
@HiveField(1) @HiveField(1)
String? passphrase; String? passphrase;
ClientCertificate({required this.bytes, this.passphrase});
ClientCertificate({
required this.bytes,
this.passphrase,
});
} }

View File

@@ -7,9 +7,16 @@ class ClientCertificateFormModel {
final Uint8List bytes; final Uint8List bytes;
final String? passphrase; 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( return ClientCertificateFormModel(
bytes: bytes ?? this.bytes, bytes: bytes ?? this.bytes,
passphrase: passphrase ?? this.passphrase, passphrase: passphrase ?? this.passphrase,

View File

@@ -3,27 +3,21 @@ import 'dart:async';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:flutter_form_builder/flutter_form_builder.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_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/exception/server_message_exception.dart';
import 'package:paperless_mobile/core/model/info_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.dart';
import 'package:paperless_mobile/features/login/model/client_certificate_form_model.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/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/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/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/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/generated/l10n/app_localizations.dart';
import 'package:paperless_mobile/helpers/message_helpers.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 { class AddAccountPage extends StatefulWidget {
final FutureOr<void> Function( final FutureOr<void> Function(
BuildContext context, BuildContext context,
@@ -33,17 +27,27 @@ class AddAccountPage extends StatefulWidget {
ClientCertificate? clientCertificate, ClientCertificate? clientCertificate,
) onSubmit; ) onSubmit;
final String submitText; final String? initialServerUrl;
final String titleString; final String? initialUsername;
final String? initialPassword;
final ClientCertificate? initialClientCertificate;
final String submitText;
final String titleText;
final bool showLocalAccounts; final bool showLocalAccounts;
final Widget? bottomLeftButton;
const AddAccountPage({ const AddAccountPage({
Key? key, Key? key,
required this.onSubmit, required this.onSubmit,
required this.submitText, required this.submitText,
required this.titleString, required this.titleText,
this.showLocalAccounts = false, this.showLocalAccounts = false,
this.initialServerUrl,
this.initialUsername,
this.initialPassword,
this.initialClientCertificate,
this.bottomLeftButton,
}) : super(key: key); }) : super(key: key);
@override @override
@@ -52,86 +56,170 @@ class AddAccountPage extends StatefulWidget {
class _AddAccountPageState extends State<AddAccountPage> { class _AddAccountPageState extends State<AddAccountPage> {
final _formKey = GlobalKey<FormBuilderState>(); final _formKey = GlobalKey<FormBuilderState>();
bool _isCheckingConnection = false;
ReachabilityStatus _reachabilityStatus = ReachabilityStatus.unknown;
final PageController _pageController = PageController(); bool _isFormSubmitted = false;
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return ValueListenableBuilder( return Scaffold(
valueListenable: appBar: AppBar(
Hive.box<LocalUserAccount>(HiveBoxes.localUserAccount).listenable(), title: Text(widget.titleText),
builder: (context, localAccounts, child) { ),
return Scaffold( bottomNavigationBar: BottomAppBar(
resizeToAvoidBottomInset: false, child: Row(
body: FormBuilder( mainAxisAlignment: widget.bottomLeftButton != null
key: _formKey, ? MainAxisAlignment.spaceBetween
child: PageView( : MainAxisAlignment.end,
controller: _pageController, children: [
scrollBehavior: NeverScrollableScrollBehavior(), if (widget.bottomLeftButton != null) widget.bottomLeftButton!,
children: [ FilledButton(
if (widget.showLocalAccounts && localAccounts.isNotEmpty) child: Text(S.of(context)!.loginPageSignInTitle),
Scaffold( onPressed: _reachabilityStatus == ReachabilityStatus.reachable &&
appBar: AppBar( !_isFormSubmitted
title: Text(S.of(context)!.logInToExistingAccount), ? _onSubmit
), : null,
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<AuthenticationCubit>()
.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,
),
],
), ),
), ],
); ),
}, ),
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<void> _login() async { Future<void> _updateReachability([String? address]) async {
setState(() {
_isCheckingConnection = true;
});
final certForm =
_formKey.currentState?.getRawValue<ClientCertificateFormModel>(
ClientCertificateFormField.fkClientCertificate,
);
final status = await context
.read<ConnectivityStatusService>()
.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<void> _onSubmit() async {
FocusScope.of(context).unfocus(); FocusScope.of(context).unfocus();
setState(() {
_isFormSubmitted = true;
});
if (_formKey.currentState?.saveAndValidate() ?? false) { if (_formKey.currentState?.saveAndValidate() ?? false) {
final form = _formKey.currentState!.value; final form = _formKey.currentState!.value;
ClientCertificate? clientCert; ClientCertificate? clientCert;
@@ -162,6 +250,10 @@ class _AddAccountPageState extends State<AddAccountPage> {
showInfoMessage(context, error); showInfoMessage(context, error);
} catch (error) { } catch (error) {
showGenericError(context, error); showGenericError(context, error);
} finally {
setState(() {
_isFormSubmitted = false;
});
} }
} }
} }

View File

@@ -1,9 +1,9 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:go_router/go_router.dart';
import 'package:hive_flutter/adapters.dart'; import 'package:hive_flutter/adapters.dart';
import 'package:paperless_api/paperless_api.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_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/database/tables/global_settings.dart';
import 'package:paperless_mobile/core/model/info_message_exception.dart'; import 'package:paperless_mobile/core/model/info_message_exception.dart';
import 'package:paperless_mobile/features/app_intro/application_intro_slideshow.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/features/login/view/add_account_page.dart';
import 'package:paperless_mobile/generated/l10n/app_localizations.dart'; import 'package:paperless_mobile/generated/l10n/app_localizations.dart';
import 'package:paperless_mobile/helpers/message_helpers.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 { 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 @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return AddAccountPage( return AddAccountPage(
titleString: S.of(context)!.connectToPaperless, titleText: S.of(context)!.connectToPaperless,
submitText: S.of(context)!.signIn, submitText: S.of(context)!.signIn,
onSubmit: _onLogin, onSubmit: _onLogin,
showLocalAccounts: true, 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,
); );
} }

View File

@@ -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<AuthenticationCubit>()
.switchAccount(account.id);
},
trailing: IconButton(
tooltip: S.of(context)!.remove,
icon: Icon(Icons.close),
onPressed: () {
context
.read<AuthenticationCubit>()
.removeAccount(account.id);
},
),
),
);
},
itemCount: localAccounts.length,
),
);
},
);
}
}

View File

@@ -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<AuthenticationCubit>().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),
],
),
),
);
}
}

View File

@@ -1,4 +1,5 @@
import 'dart:io'; import 'dart:io';
import 'dart:typed_data';
import 'package:file_picker/file_picker.dart'; import 'package:file_picker/file_picker.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
@@ -12,11 +13,16 @@ import 'obscured_input_text_form_field.dart';
class ClientCertificateFormField extends StatefulWidget { class ClientCertificateFormField extends StatefulWidget {
static const fkClientCertificate = 'clientCertificate'; static const fkClientCertificate = 'clientCertificate';
final String? initialPassphrase;
final Uint8List? initialBytes;
final void Function(ClientCertificateFormModel? cert) onChanged; final void Function(ClientCertificateFormModel? cert) onChanged;
const ClientCertificateFormField({ const ClientCertificateFormField({
Key? key, super.key,
required this.onChanged, required this.onChanged,
}) : super(key: key); this.initialPassphrase,
this.initialBytes,
});
@override @override
State<ClientCertificateFormField> createState() => State<ClientCertificateFormField> createState() =>
@@ -31,7 +37,12 @@ class _ClientCertificateFormFieldState
return FormBuilderField<ClientCertificateFormModel?>( return FormBuilderField<ClientCertificateFormModel?>(
key: const ValueKey('login-client-cert'), key: const ValueKey('login-client-cert'),
onChanged: widget.onChanged, onChanged: widget.onChanged,
initialValue: null, initialValue: widget.initialBytes != null
? ClientCertificateFormModel(
bytes: widget.initialBytes!,
passphrase: widget.initialPassphrase,
)
: null,
validator: (value) { validator: (value) {
if (value == null) { if (value == null) {
return null; return null;
@@ -108,8 +119,7 @@ class _ClientCertificateFormFieldState
), ),
label: S.of(context)!.passphrase, label: S.of(context)!.passphrase,
).padded(), ).padded(),
] else ]
...[]
], ],
), ),
), ),
@@ -122,20 +132,23 @@ class _ClientCertificateFormFieldState
} }
Future<void> _onSelectFile( Future<void> _onSelectFile(
FormFieldState<ClientCertificateFormModel?> field) async { FormFieldState<ClientCertificateFormModel?> field,
FilePickerResult? result = await FilePicker.platform.pickFiles( ) async {
final result = await FilePicker.platform.pickFiles(
allowMultiple: false, allowMultiple: false,
); );
if (result != null && result.files.single.path != null) { if (result == null || result.files.single.path == null) {
File file = File(result.files.single.path!); return;
setState(() {
_selectedFile = file;
});
final changedValue =
field.value?.copyWith(bytes: file.readAsBytesSync()) ??
ClientCertificateFormModel(bytes: file.readAsBytesSync());
field.didChange(changedValue);
} }
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( Widget _buildSelectedFileText(

View File

@@ -8,11 +8,12 @@ import 'package:paperless_mobile/generated/l10n/app_localizations.dart';
class ServerAddressFormField extends StatefulWidget { class ServerAddressFormField extends StatefulWidget {
static const String fkServerAddress = "serverAddress"; static const String fkServerAddress = "serverAddress";
final String? initialValue;
final void Function(String? address) onSubmit; final void Function(String? address) onSubmit;
const ServerAddressFormField({ const ServerAddressFormField({
Key? key, Key? key,
required this.onSubmit, required this.onSubmit,
this.initialValue,
}) : super(key: key); }) : super(key: key);
@override @override
@@ -38,6 +39,7 @@ class _ServerAddressFormFieldState extends State<ServerAddressFormField> {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return FormBuilderField<String>( return FormBuilderField<String>(
initialValue: widget.initialValue,
name: ServerAddressFormField.fkServerAddress, name: ServerAddressFormField.fkServerAddress,
autovalidateMode: AutovalidateMode.onUserInteraction, autovalidateMode: AutovalidateMode.onUserInteraction,
validator: (value) { validator: (value) {
@@ -90,7 +92,7 @@ class _ServerAddressFormFieldState extends State<ServerAddressFormField> {
) )
: null, : null,
), ),
autofocus: true, autofocus: false,
onSubmitted: (_) { onSubmitted: (_) {
onFieldSubmitted(); onFieldSubmitted();
_formatInput(); _formatInput();

View File

@@ -1,19 +1,27 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter_form_builder/flutter_form_builder.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/extensions/flutter_extensions.dart';
import 'package:paperless_mobile/features/login/model/login_form_credentials.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/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'; import 'package:paperless_mobile/generated/l10n/app_localizations.dart';
class UserCredentialsFormField extends StatefulWidget { class UserCredentialsFormField extends StatefulWidget {
static const fkCredentials = 'credentials'; static const fkCredentials = 'credentials';
final void Function() onFieldsSubmitted; final void Function() onFieldsSubmitted;
final String? initialUsername;
final String? initialPassword;
final GlobalKey<FormBuilderState> formKey;
const UserCredentialsFormField({ const UserCredentialsFormField({
Key? key, Key? key,
required this.onFieldsSubmitted, required this.onFieldsSubmitted,
this.initialUsername,
this.initialPassword,
required this.formKey,
}) : super(key: key); }) : super(key: key);
@override @override
@@ -28,6 +36,10 @@ class _UserCredentialsFormFieldState extends State<UserCredentialsFormField> {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return FormBuilderField<LoginFormCredentials?>( return FormBuilderField<LoginFormCredentials?>(
initialValue: LoginFormCredentials(
password: widget.initialPassword,
username: widget.initialUsername,
),
name: UserCredentialsFormField.fkCredentials, name: UserCredentialsFormField.fkCredentials,
builder: (field) => AutofillGroup( builder: (field) => AutofillGroup(
child: Column( child: Column(
@@ -50,6 +62,17 @@ class _UserCredentialsFormFieldState extends State<UserCredentialsFormField> {
if (value?.trim().isEmpty ?? true) { if (value?.trim().isEmpty ?? true) {
return S.of(context)!.usernameMustNotBeEmpty; return S.of(context)!.usernameMustNotBeEmpty;
} }
final serverAddress = widget.formKey.currentState!
.getRawValue<String>(
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; return null;
}, },
autofillHints: const [AutofillHints.username], autofillHints: const [AutofillHints.username],

View File

@@ -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<FormBuilderState> formBuilderKey;
final VoidCallback onContinue;
final String titleText;
const ServerConnectionPage({
super.key,
required this.formBuilderKey,
required this.onContinue,
required this.titleText,
});
@override
State<ServerConnectionPage> createState() => _ServerConnectionPageState();
}
class _ServerConnectionPageState extends State<ServerConnectionPage> {
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<void> _updateReachability([String? address]) async {
setState(() {
_isCheckingConnection = true;
});
final certForm = widget.formBuilderKey.currentState
?.getRawValue(ClientCertificateFormField.fkClientCertificate)
as ClientCertificateFormModel?;
final status = await context
.read<ConnectivityStatusService>()
.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,
),
);
}
}

View File

@@ -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<void> Function() onSubmit;
final GlobalKey<FormBuilderState> formBuilderKey;
const ServerLoginPage({
super.key,
required this.onSubmit,
required this.formBuilderKey,
required this.submitText,
});
@override
State<ServerLoginPage> createState() => _ServerLoginPageState();
}
class _ServerLoginPageState extends State<ServerLoginPage> {
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),
)
],
),
),
);
}
}

View File

@@ -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<SystemUiOverlayStyle>(
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),
),
),
);
}
}

View File

@@ -1,8 +0,0 @@
import 'package:flutter/widgets.dart';
class NeverScrollableScrollBehavior extends ScrollBehavior {
@override
ScrollPhysics getScrollPhysics(BuildContext context) {
return const NeverScrollableScrollPhysics();
}
}

View File

@@ -54,6 +54,7 @@ class LocalNotificationService {
required bool finished, required bool finished,
required String locale, required String locale,
required String userId, required String userId,
double? progress,
}) async { }) async {
final tr = await S.delegate.load(Locale(locale)); final tr = await S.delegate.load(Locale(locale));
@@ -68,8 +69,10 @@ class LocalNotificationService {
android: AndroidNotificationDetails( android: AndroidNotificationDetails(
NotificationChannel.documentDownload.id + "_${document.id}", NotificationChannel.documentDownload.id + "_${document.id}",
NotificationChannel.documentDownload.name, NotificationChannel.documentDownload.name,
progress: ((progress ?? 0) * 100).toInt(),
maxProgress: 100,
indeterminate: progress == null && !finished,
ongoing: !finished, ongoing: !finished,
indeterminate: true,
importance: Importance.max, importance: Importance.max,
priority: Priority.high, priority: Priority.high,
showProgress: !finished, showProgress: !finished,

View File

@@ -3,7 +3,6 @@ import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:paperless_api/paperless_api.dart'; import 'package:paperless_api/paperless_api.dart';
import 'package:paperless_mobile/core/notifier/document_changed_notifier.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/core/service/connectivity_status_service.dart';
import 'package:rxdart/streams.dart';
import 'paged_documents_state.dart'; import 'paged_documents_state.dart';
@@ -51,6 +50,7 @@ mixin DocumentPagingBlocMixin<State extends DocumentPagingState>
/// Use [loadMore] to load more data. /// Use [loadMore] to load more data.
Future<void> updateFilter({ Future<void> updateFilter({
final DocumentFilter filter = const DocumentFilter(), final DocumentFilter filter = const DocumentFilter(),
bool emitLoading = true,
}) async { }) async {
final hasConnection = final hasConnection =
await connectivityStatusService.isConnectedToInternet(); await connectivityStatusService.isConnectedToInternet();
@@ -60,7 +60,9 @@ mixin DocumentPagingBlocMixin<State extends DocumentPagingState>
.expand((page) => page.results) .expand((page) => page.results)
.where((doc) => filter.matches(doc)) .where((doc) => filter.matches(doc))
.toList(); .toList();
emit(state.copyWithPaged(isLoading: true)); if (emitLoading) {
emit(state.copyWithPaged(isLoading: true));
}
emit( emit(
state.copyWithPaged( state.copyWithPaged(
@@ -79,7 +81,9 @@ mixin DocumentPagingBlocMixin<State extends DocumentPagingState>
return; return;
} }
try { try {
emit(state.copyWithPaged(isLoading: true)); if (emitLoading) {
emit(state.copyWithPaged(isLoading: true));
}
final result = await api.findAll(filter.copyWith(page: 1)); final result = await api.findAll(filter.copyWith(page: 1));
emit( emit(
@@ -146,7 +150,7 @@ mixin DocumentPagingBlocMixin<State extends DocumentPagingState>
/// Deletes a document and removes it from the currently loaded state. /// Deletes a document and removes it from the currently loaded state.
/// ///
Future<void> delete(DocumentModel document) async { Future<void> delete(DocumentModel document) async {
emit(state.copyWithPaged(isLoading: true)); // emit(state.copyWithPaged(isLoading: true));
try { try {
await api.delete(document); await api.delete(document);
notifier.notifyDeleted(document); notifier.notifyDeleted(document);
@@ -213,6 +217,7 @@ mixin DocumentPagingBlocMixin<State extends DocumentPagingState>
} }
} }
@override @override
Future<void> close() { Future<void> close() {
notifier.removeListener(this); notifier.removeListener(this);

View File

@@ -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<ConnectivityCubit, ConnectivityState>(
builder: (context, connectivity) {
return BlocBuilder<SavedViewCubit, SavedViewState>(
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.",
),
),
),
);
},
);
},
);
}
}

View File

@@ -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,
),
),
),
),
);
}
}

View File

@@ -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<void> Function(SavedView savedView) onDelete;
const SavedViewDetailsPage({
super.key,
required this.onDelete,
});
@override
State<SavedViewDetailsPage> createState() => _SavedViewDetailsPageState();
}
class _SavedViewDetailsPageState extends State<SavedViewDetailsPage>
with DocumentPagingViewMixin<SavedViewDetailsPage, SavedViewDetailsCubit> {
@override
final pagingScrollController = ScrollController();
@override
Widget build(BuildContext context) {
final cubit = context.watch<SavedViewDetailsCubit>();
return Scaffold(
appBar: AppBar(
title: Text(cubit.savedView.name),
actions: [
IconButton(
icon: const Icon(Icons.delete),
onPressed: () async {
final shouldDelete = await showDialog<bool>(
context: context,
builder: (context) => ConfirmDeleteSavedViewDialog(
view: cubit.savedView,
),
) ??
false;
if (shouldDelete) {
await widget.onDelete(cubit.savedView);
context.pop(context);
}
},
),
BlocBuilder<SavedViewDetailsCubit, SavedViewDetailsState>(
builder: (context, state) {
return ViewTypeSelectionWidget(
viewType: state.viewType,
onChanged: cubit.setViewType,
);
},
),
],
),
body: BlocBuilder<SavedViewDetailsCubit, SavedViewDetailsState>(
builder: (context, state) {
if (state.hasLoaded && state.documents.isEmpty) {
return DocumentsEmptyState(state: state);
}
return BlocBuilder<ConnectivityCubit, ConnectivityState>(
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(),
),
)
],
);
},
);
},
),
);
}
}

View File

@@ -1,5 +1,3 @@
import 'dart:ui';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:paperless_api/paperless_api.dart'; import 'package:paperless_api/paperless_api.dart';

View File

@@ -1,16 +1,13 @@
import 'package:collection/collection.dart'; import 'package:collection/collection.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:hive_flutter/adapters.dart'; import 'package:hive_flutter/adapters.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/features/home/view/model/api_version.dart';
import 'package:paperless_mobile/features/login/cubit/authentication_cubit.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/dialogs/switch_account_dialog.dart';
import 'package:paperless_mobile/features/settings/view/widgets/global_settings_builder.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/features/users/view/widgets/user_account_list_tile.dart';
import 'package:paperless_mobile/generated/l10n/app_localizations.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'; import 'package:provider/provider.dart';
class ManageAccountsPage extends StatelessWidget { class ManageAccountsPage extends StatelessWidget {
@@ -20,16 +17,15 @@ class ManageAccountsPage extends StatelessWidget {
Widget build(BuildContext context) { Widget build(BuildContext context) {
return GlobalSettingsBuilder( return GlobalSettingsBuilder(
builder: (context, globalSettings) { builder: (context, globalSettings) {
// This is one of the few places where the currentLoggedInUser can be null // // This is one of the few places where the currentLoggedInUser can be null
// (exactly after loggin out as the current user to be precise). // // (exactly after loggin out as the current user to be precise).
if (globalSettings.loggedInUserId == null) {
return const SizedBox.shrink();
}
return ValueListenableBuilder( return ValueListenableBuilder(
valueListenable: valueListenable: Hive.localUserAccountBox.listenable(),
Hive.box<LocalUserAccount>(HiveBoxes.localUserAccount)
.listenable(),
builder: (context, box, _) { builder: (context, box, _) {
if (globalSettings.loggedInUserId == null) {
return const SizedBox.shrink();
}
final userIds = box.keys.toList().cast<String>(); final userIds = box.keys.toList().cast<String>();
final otherAccounts = userIds final otherAccounts = userIds
.whereNot((element) => element == globalSettings.loggedInUserId) .whereNot((element) => element == globalSettings.loggedInUserId)
@@ -70,6 +66,7 @@ class ManageAccountsPage extends StatelessWidget {
], ],
onSelected: (value) async { onSelected: (value) async {
if (value == 0) { if (value == 0) {
Navigator.of(context).pop();
await context await context
.read<AuthenticationCubit>() .read<AuthenticationCubit>()
.logout(true); .logout(true);
@@ -133,11 +130,12 @@ class ManageAccountsPage extends StatelessWidget {
_onAddAccount(context, globalSettings.loggedInUserId!); _onAddAccount(context, globalSettings.loggedInUserId!);
}, },
), ),
if (context.watch<LocalUserAccount>().hasMultiUserSupport) //TODO: Implement permission/user settings at some point...
ListTile( // if (context.watch<LocalUserAccount>().hasMultiUserSupport)
leading: const Icon(Icons.admin_panel_settings), // ListTile(
title: Text(S.of(context)!.managePermissions), // leading: const Icon(Icons.admin_panel_settings),
), // title: Text(S.of(context)!.managePermissions),
// ),
], ],
); );
}, },
@@ -147,43 +145,43 @@ class ManageAccountsPage extends StatelessWidget {
} }
Future<void> _onAddAccount(BuildContext context, String currentUser) async { Future<void> _onAddAccount(BuildContext context, String currentUser) async {
final userId = await Navigator.push( Navigator.of(context).pop();
context, AddAccountRoute().push<String>(context);
MaterialPageRoute( // final userId = await Navigator.push(
builder: (context) => AddAccountPage( // context,
titleString: S.of(context)!.addAccount, // MaterialPageRoute(
onSubmit: (context, username, password, serverUrl, // builder: (context) => AddAccountPage(
clientCertificate) async { // titleText: S.of(context)!.addAccount,
final userId = await context.read<AuthenticationCubit>().addAccount( // onSubmit: (context, username, password, serverUrl,
credentials: LoginFormCredentials( // clientCertificate) async {
username: username, // try {
password: password, // final userId =
), // await context.read<AuthenticationCubit>().addAccount(
clientCertificate: clientCertificate, // credentials: LoginFormCredentials(
serverUrl: serverUrl, // username: username,
//TODO: Ask user whether to enable biometric authentication // password: password,
enableBiometricAuthentication: false, // ),
); // clientCertificate: clientCertificate,
Navigator.of(context).pop<String?>(userId); // serverUrl: serverUrl,
}, // //TODO: Ask user whether to enable biometric authentication
submitText: S.of(context)!.addAccount, // enableBiometricAuthentication: false,
), // );
),
); // Navigator.of(context).pop<String?>(userId);
if (userId != null) { // } on PaperlessFormValidationException catch (error) {}
final shoudSwitch = await showDialog<bool>( // },
context: context, // submitText: S.of(context)!.addAccount,
builder: (context) => const SwitchAccountDialog(), // ),
) ?? // ),
false; // );
if (shoudSwitch) {
_onSwitchAccount(context, currentUser, userId);
}
}
} }
void _onSwitchAccount( void _onSwitchAccount(
BuildContext context, String currentUser, String newUser) async { BuildContext context,
String currentUser,
String newUser,
) async {
if (currentUser == newUser) return; if (currentUser == newUser) return;
Navigator.of(context).pop(); Navigator.of(context).pop();

View File

@@ -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,
),
],
),
),
),
);
}
}

View File

@@ -21,6 +21,7 @@ class _LanguageSelectionSettingState extends State<LanguageSelectionSetting> {
'tr': LanguageOption('Türkçe', true), 'tr': LanguageOption('Türkçe', true),
'pl': LanguageOption('Polska', true), 'pl': LanguageOption('Polska', true),
'ca': LanguageOption('Catalan', true), 'ca': LanguageOption('Catalan', true),
'ru': LanguageOption('Русский', true),
}; };
@override @override
@@ -34,9 +35,9 @@ class _LanguageSelectionSettingState extends State<LanguageSelectionSetting> {
onTap: () => showDialog<String>( onTap: () => showDialog<String>(
context: context, context: context,
builder: (_) => RadioSettingsDialog<String>( builder: (_) => RadioSettingsDialog<String>(
footer: const Text( // footer: const Text(
"* Not fully translated yet. Some words may be displayed in English!", // "* Not fully translated yet. Some words may be displayed in English!",
), // ),
titleText: S.of(context)!.language, titleText: S.of(context)!.language,
options: [ options: [
for (var language in _languageOptions.entries) for (var language in _languageOptions.entries)

View File

@@ -1,5 +1,6 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:paperless_mobile/features/settings/view/widgets/global_settings_builder.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 { class SkipDocumentPreprationOnShareSetting extends StatelessWidget {
const SkipDocumentPreprationOnShareSetting({super.key}); const SkipDocumentPreprationOnShareSetting({super.key});
@@ -9,9 +10,8 @@ class SkipDocumentPreprationOnShareSetting extends StatelessWidget {
return GlobalSettingsBuilder( return GlobalSettingsBuilder(
builder: (context, settings) { builder: (context, settings) {
return SwitchListTile( return SwitchListTile(
title: Text("Direct share"), title: Text(S.of(context)!.skipEditingReceivedFiles),
subtitle: subtitle: Text(S.of(context)!.uploadWithoutPromptingUploadForm),
Text("Always directly upload when sharing files with the app."),
value: settings.skipDocumentPreprarationOnUpload, value: settings.skipDocumentPreprarationOnUpload,
onChanged: (value) { onChanged: (value) {
settings.skipDocumentPreprarationOnUpload = value; settings.skipDocumentPreprarationOnUpload = value;

View File

@@ -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<SharedMediaFile> sharedFiles,
}) async {
if (sharedFiles.isEmpty) {
return;
}
Iterable<File> 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<LocalUserAccount>().id,
);
}
}

View File

@@ -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<String, Queue<File>> _queues = {};
ShareIntentQueue._();
static final instance = ShareIntentQueue._();
Future<void> 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<void> addAll(
Iterable<File> 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<void> onConsumed(File file) {
debugPrint(
"File ${file.path} successfully consumed. Delelting local copy.");
return file.delete();
}
Future<void> 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<File> getQueue(String userId) {
if (!_queues.containsKey(userId)) {
_queues[userId] = Queue<File>();
}
return _queues[userId]!;
}
}
class UserAwareShareMediaFile {
final String userId;
final SharedMediaFile sharedFile;
UserAwareShareMediaFile(this.userId, this.sharedFile);
}

View File

@@ -1,9 +1,8 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:paperless_mobile/core/database/tables/local_user_account.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/cubit/receive_share_cubit.dart';
import 'package:paperless_mobile/features/sharing/view/widgets/file_thumbnail.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:paperless_mobile/generated/l10n/app_localizations.dart';
import 'package:path/path.dart' as p; import 'package:path/path.dart' as p;
import 'package:provider/provider.dart'; import 'package:provider/provider.dart';

View File

@@ -3,19 +3,22 @@ import 'dart:io';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter/services.dart'; import 'package:flutter/services.dart';
import 'package:flutter_animate/flutter_animate.dart';
import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:fluttertoast/fluttertoast.dart'; import 'package:fluttertoast/fluttertoast.dart';
import 'package:hive/hive.dart'; import 'package:hive/hive.dart';
import 'package:paperless_api/paperless_api.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_config.dart';
import 'package:paperless_mobile/core/config/hive/hive_extensions.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/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/core/service/connectivity_status_service.dart';
import 'package:paperless_mobile/features/document_upload/view/document_upload_preparation_page.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/notifications/services/local_notification_service.dart';
import 'package:paperless_mobile/features/sharing/cubit/receive_share_cubit.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/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/features/tasks/model/pending_tasks_notifier.dart';
import 'package:paperless_mobile/generated/l10n/app_localizations.dart'; import 'package:paperless_mobile/generated/l10n/app_localizations.dart';
import 'package:paperless_mobile/helpers/message_helpers.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:path/path.dart' as p;
import 'package:receive_sharing_intent/receive_sharing_intent.dart'; import 'package:receive_sharing_intent/receive_sharing_intent.dart';
class UploadQueueShell extends StatefulWidget { class EventListenerShell extends StatefulWidget {
final Widget child; final Widget child;
const UploadQueueShell({super.key, required this.child}); const EventListenerShell({super.key, required this.child});
@override @override
State<UploadQueueShell> createState() => _UploadQueueShellState(); State<EventListenerShell> createState() => _EventListenerShellState();
} }
class _UploadQueueShellState extends State<UploadQueueShell> { class _EventListenerShellState extends State<EventListenerShell>
with WidgetsBindingObserver {
StreamSubscription? _subscription; StreamSubscription? _subscription;
StreamSubscription? _documentDeletedSubscription;
Timer? _inboxTimer;
@override @override
void initState() { void initState() {
super.initState(); super.initState();
WidgetsBinding.instance.addObserver(this);
ReceiveSharingIntent.getInitialMedia().then(_onReceiveSharedFiles); ReceiveSharingIntent.getInitialMedia().then(_onReceiveSharedFiles);
_subscription = _subscription =
ReceiveSharingIntent.getMediaStream().listen(_onReceiveSharedFiles); ReceiveSharingIntent.getMediaStream().listen(_onReceiveSharedFiles);
context.read<PendingTasksNotifier>().addListener(_onTasksChanged); context.read<PendingTasksNotifier>().addListener(_onTasksChanged);
_documentDeletedSubscription =
context.read<DocumentChangedNotifier>().$deleted.listen((event) {
showSnackBar(context, S.of(context)!.documentSuccessfullyDeleted);
});
_listenToInboxChanges();
// WidgetsBinding.instance.addPostFrameCallback((_) async { // WidgetsBinding.instance.addPostFrameCallback((_) async {
// final notifier = context.read<ConsumptionChangeNotifier>(); // final notifier = context.read<ConsumptionChangeNotifier>();
// await notifier.isInitialized; // await notifier.isInitialized;
@@ -67,6 +78,52 @@ class _UploadQueueShellState extends State<UploadQueueShell> {
// }); // });
} }
void _listenToInboxChanges() {
final cubit = context.read<InboxCubit>();
final currentUser = context.read<LocalUserAccount>();
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<ConnectivityCubit>().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() { void _onTasksChanged() {
final taskNotifier = context.read<PendingTasksNotifier>(); final taskNotifier = context.read<PendingTasksNotifier>();
final userId = context.read<LocalUserAccount>().id; final userId = context.read<LocalUserAccount>().id;
@@ -96,12 +153,6 @@ class _UploadQueueShellState extends State<UploadQueueShell> {
} }
} }
@override
void dispose() {
_subscription?.cancel();
super.dispose();
}
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return widget.child; return widget.child;
@@ -167,9 +218,9 @@ Future<void> consumeLocalFile(
); );
await consumptionNotifier.discardFile(file, userId: userId); await consumptionNotifier.discardFile(file, userId: userId);
if (result.taskId != null) { // if (result.taskId != null) {
taskNotifier.listenToTaskChanges(result.taskId!); // taskNotifier.listenToTaskChanges(result.taskId!);
} // }
if (exitAppAfterConsumed) { if (exitAppAfterConsumed) {
SystemNavigator.pop(); SystemNavigator.pop();
} }

View File

@@ -52,10 +52,10 @@ class PendingTasksNotifier extends ValueNotifier<Map<String, Task>> {
_subscriptions[taskId]?.cancel(); _subscriptions[taskId]?.cancel();
_subscriptions.remove(taskId); _subscriptions.remove(taskId);
} else { } else {
_subscriptions.forEach((key, value) { for (var sub in _subscriptions.values) {
value.cancel(); sub.cancel();
_subscriptions.remove(key); }
}); _subscriptions.clear();
} }
} }

View File

@@ -1,3 +0,0 @@
String extractFilenameFromPath(String path) {
return path.split(RegExp('[./]')).reversed.skip(1).first;
}

View File

@@ -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<void> loadImage(ImageProvider provider) {
final config = ImageConfiguration(
bundle: rootBundle,
devicePixelRatio: window.devicePixelRatio,
platform: defaultTargetPlatform,
);
final Completer<void> 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;
}

View File

@@ -980,5 +980,21 @@
}, },
"tryAgain": "Try again", "tryAgain": "Try again",
"discardFile": "Discard file?", "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"
}
} }

View File

@@ -980,5 +980,21 @@
}, },
"tryAgain": "Try again", "tryAgain": "Try again",
"discardFile": "Discard file?", "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"
}
} }

View File

@@ -980,5 +980,21 @@
}, },
"tryAgain": "Erneut versuchen", "tryAgain": "Erneut versuchen",
"discardFile": "Datei verwerfen?", "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"
}
} }

View File

@@ -980,5 +980,21 @@
}, },
"tryAgain": "Try again", "tryAgain": "Try again",
"discardFile": "Discard file?", "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"
}
} }

View File

@@ -980,5 +980,21 @@
}, },
"tryAgain": "Try again", "tryAgain": "Try again",
"discardFile": "Discard file?", "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"
}
} }

View File

@@ -980,5 +980,21 @@
}, },
"tryAgain": "Try again", "tryAgain": "Try again",
"discardFile": "Discard file?", "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"
}
} }

View File

@@ -980,5 +980,21 @@
}, },
"tryAgain": "Try again", "tryAgain": "Try again",
"discardFile": "Discard file?", "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"
}
} }

View File

@@ -980,5 +980,21 @@
}, },
"tryAgain": "Try again", "tryAgain": "Try again",
"discardFile": "Discard file?", "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"
}
} }

View File

@@ -980,5 +980,21 @@
}, },
"tryAgain": "Try again", "tryAgain": "Try again",
"discardFile": "Discard file?", "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"
}
} }

View File

@@ -15,7 +15,6 @@ import 'package:hydrated_bloc/hydrated_bloc.dart';
import 'package:intl/date_symbol_data_local.dart'; import 'package:intl/date_symbol_data_local.dart';
import 'package:intl/intl_standalone.dart'; import 'package:intl/intl_standalone.dart';
import 'package:local_auth/local_auth.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:package_info_plus/package_info_plus.dart';
import 'package:paperless_api/paperless_api.dart'; import 'package:paperless_api/paperless_api.dart';
import 'package:paperless_mobile/constants.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.dart';
import 'package:paperless_mobile/core/factory/paperless_api_factory_impl.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/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/notifier/document_changed_notifier.dart';
import 'package:paperless_mobile/core/security/session_manager.dart'; import 'package:paperless_mobile/core/security/session_manager.dart';
import 'package:paperless_mobile/core/service/connectivity_status_service.dart'; import 'package:paperless_mobile/core/service/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/login/services/authentication_service.dart';
import 'package:paperless_mobile/features/notifications/services/local_notification_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/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/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/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/documents_route.dart';
import 'package:paperless_mobile/routes/typed/branches/inbox_route.dart'; import 'package:paperless_mobile/routes/typed/branches/inbox_route.dart';
import 'package:paperless_mobile/routes/typed/branches/labels_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/branches/upload_queue_route.dart';
import 'package:paperless_mobile/routes/typed/shells/provider_shell_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/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/logging_out_route.dart';
import 'package:paperless_mobile/routes/typed/top_level/login_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/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:paperless_mobile/theme.dart';
import 'package:path_provider/path_provider.dart'; import 'package:path_provider/path_provider.dart';
import 'package:provider/provider.dart'; import 'package:provider/provider.dart';
@@ -157,9 +150,8 @@ void main() async {
apiFactory, apiFactory,
sessionManager, sessionManager,
connectivityStatusService, connectivityStatusService,
localNotificationService,
); );
await authenticationCubit.restoreSessionState();
await ShareIntentQueue.instance.initialize();
runApp( runApp(
MultiProvider( MultiProvider(
providers: [ providers: [
@@ -208,11 +200,14 @@ class _GoRouterShellState extends State<GoRouterShell> {
@override @override
void initState() { void initState() {
super.initState(); super.initState();
FlutterNativeSplash.remove();
if (Platform.isAndroid) { if (Platform.isAndroid) {
_setOptimalDisplayMode(); _setOptimalDisplayMode();
} }
initializeDateFormatting(); initializeDateFormatting();
WidgetsBinding.instance.addPostFrameCallback((_) async {
context.read<AuthenticationCubit>().restoreSession();
FlutterNativeSplash.remove();
});
} }
/// Activates the highest supported refresh rate on the device. /// Activates the highest supported refresh rate on the device.
@@ -236,43 +231,96 @@ class _GoRouterShellState extends State<GoRouterShell> {
debugLogDiagnostics: kDebugMode, debugLogDiagnostics: kDebugMode,
initialLocation: "/login", initialLocation: "/login",
routes: [ routes: [
$loginRoute,
$verifyIdentityRoute,
$switchingAccountsRoute,
$logginOutRoute,
$checkingLoginRoute,
ShellRoute( ShellRoute(
pageBuilder: (context, state, child) {
return MaterialPage(
child: BlocListener<AuthenticationCubit, AuthenticationState>(
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, navigatorKey: rootNavigatorKey,
builder: ProviderShellRoute(widget.apiFactory).build,
routes: [ routes: [
$settingsRoute, $loginRoute,
$savedViewsRoute, $loggingOutRoute,
$uploadQueueRoute, $addAccountRoute,
StatefulShellRoute( ShellRoute(
navigatorContainerBuilder: (context, navigationShell, children) { navigatorKey: outerShellNavigatorKey,
return children[navigationShell.currentIndex]; builder: ProviderShellRoute(widget.apiFactory).build,
}, routes: [
builder: const ScaffoldShellRoute().builder, $settingsRoute,
branches: [ $savedViewsRoute,
StatefulShellBranch( $uploadQueueRoute,
navigatorKey: landingNavigatorKey, StatefulShellRoute(
routes: [$landingRoute], navigatorContainerBuilder:
), (context, navigationShell, children) {
StatefulShellBranch( return children[navigationShell.currentIndex];
navigatorKey: documentsNavigatorKey, },
routes: [$documentsRoute], builder: const ScaffoldShellRoute().builder,
), branches: [
StatefulShellBranch( StatefulShellBranch(
navigatorKey: scannerNavigatorKey, navigatorKey: landingNavigatorKey,
routes: [$scannerRoute], routes: [$landingRoute],
), ),
StatefulShellBranch( StatefulShellBranch(
navigatorKey: labelsNavigatorKey, navigatorKey: documentsNavigatorKey,
routes: [$labelsRoute], routes: [$documentsRoute],
), ),
StatefulShellBranch( StatefulShellBranch(
navigatorKey: inboxNavigatorKey, navigatorKey: scannerNavigatorKey,
routes: [$inboxRoute], routes: [$scannerRoute],
),
StatefulShellBranch(
navigatorKey: labelsNavigatorKey,
routes: [$labelsRoute],
),
StatefulShellBranch(
navigatorKey: inboxNavigatorKey,
routes: [$inboxRoute],
),
],
), ),
], ],
), ),
@@ -283,69 +331,34 @@ class _GoRouterShellState extends State<GoRouterShell> {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return BlocListener<AuthenticationCubit, AuthenticationState>( return GlobalSettingsBuilder(
listener: (context, state) { builder: (context, settings) {
switch (state) { return DynamicColorBuilder(
case UnauthenticatedState(): builder: (lightDynamic, darkDynamic) {
_router.goNamed(R.login); return MaterialApp.router(
break; routerConfig: _router,
case RequiresLocalAuthenticationState(): debugShowCheckedModeBanner: true,
_router.goNamed(R.verifyIdentity); title: "Paperless Mobile",
break; theme: buildTheme(
case SwitchingAccountsState(): brightness: Brightness.light,
final userId = context.read<LocalUserAccount>().id; dynamicScheme: lightDynamic,
context preferredColorScheme: settings.preferredColorSchemeOption,
.read<LocalNotificationService>() ),
.cancelUserNotifications(userId); darkTheme: buildTheme(
_router.goNamed(R.switchingAccounts); brightness: Brightness.dark,
break; dynamicScheme: darkDynamic,
case AuthenticatedState(): preferredColorScheme: settings.preferredColorSchemeOption,
_router.goNamed(R.landing); ),
break; themeMode: settings.preferredThemeMode,
case CheckingLoginState(): supportedLocales: S.supportedLocales,
_router.goNamed(R.checkingLogin); locale: Locale.fromSubtags(
break; languageCode: settings.preferredLocaleSubtag,
case LogginOutState(): ),
final userId = context.read<LocalUserAccount>().id; localizationsDelegates: S.localizationsDelegates,
context );
.read<LocalNotificationService>() },
.cancelUserNotifications(userId); );
_router.goNamed(R.loggingOut);
break;
case AuthenticationErrorState():
_router.goNamed(R.login);
break;
}
}, },
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,
);
},
);
},
),
); );
} }
} }

View File

@@ -1,6 +1,7 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
final rootNavigatorKey = GlobalKey<NavigatorState>(); final rootNavigatorKey = GlobalKey<NavigatorState>();
final outerShellNavigatorKey = GlobalKey<NavigatorState>();
final landingNavigatorKey = GlobalKey<NavigatorState>(); final landingNavigatorKey = GlobalKey<NavigatorState>();
final documentsNavigatorKey = GlobalKey<NavigatorState>(); final documentsNavigatorKey = GlobalKey<NavigatorState>();
final scannerNavigatorKey = GlobalKey<NavigatorState>(); final scannerNavigatorKey = GlobalKey<NavigatorState>();

View File

@@ -2,9 +2,10 @@ class R {
const R._(); const R._();
static const landing = "landing"; static const landing = "landing";
static const login = "login"; static const login = "login";
static const loginToExistingAccount = 'loginToExistingAccount';
static const documents = "documents"; static const documents = "documents";
static const verifyIdentity = "verifyIdentity"; static const verifyIdentity = "verifyIdentity";
static const switchingAccounts = "switchingAccounts"; static const switchingAccount = "switchingAccount";
static const savedView = "savedView"; static const savedView = "savedView";
static const createSavedView = "createSavedView"; static const createSavedView = "createSavedView";
static const editSavedView = "editSavedView"; static const editSavedView = "editSavedView";
@@ -21,6 +22,8 @@ class R {
static const linkedDocuments = "linkedDocuments"; static const linkedDocuments = "linkedDocuments";
static const bulkEditDocuments = "bulkEditDocuments"; static const bulkEditDocuments = "bulkEditDocuments";
static const uploadQueue = "uploadQueue"; static const uploadQueue = "uploadQueue";
static const checkingLogin = "checkingLogin"; static const authenticating = "authenticating";
static const loggingOut = "loggingOut"; static const loggingOut = "loggingOut";
static const restoringSession = "restoringSession";
static const addAccount = 'addAccount';
} }

View File

@@ -54,7 +54,8 @@ class DocumentsRoute extends GoRouteData {
} }
class DocumentDetailsRoute extends GoRouteData { class DocumentDetailsRoute extends GoRouteData {
static final GlobalKey<NavigatorState> $parentNavigatorKey = rootNavigatorKey; static final GlobalKey<NavigatorState> $parentNavigatorKey =
outerShellNavigatorKey;
final bool isLabelClickable; final bool isLabelClickable;
final DocumentModel $extra; final DocumentModel $extra;
@@ -86,7 +87,8 @@ class DocumentDetailsRoute extends GoRouteData {
} }
class EditDocumentRoute extends GoRouteData { class EditDocumentRoute extends GoRouteData {
static final GlobalKey<NavigatorState> $parentNavigatorKey = rootNavigatorKey; static final GlobalKey<NavigatorState> $parentNavigatorKey =
outerShellNavigatorKey;
final DocumentModel $extra; final DocumentModel $extra;
@@ -114,7 +116,8 @@ class EditDocumentRoute extends GoRouteData {
} }
class DocumentPreviewRoute extends GoRouteData { class DocumentPreviewRoute extends GoRouteData {
static final GlobalKey<NavigatorState> $parentNavigatorKey = rootNavigatorKey; static final GlobalKey<NavigatorState> $parentNavigatorKey =
outerShellNavigatorKey;
final DocumentModel $extra; final DocumentModel $extra;
final String? title; final String? title;

View File

@@ -49,7 +49,8 @@ class LabelsRoute extends GoRouteData {
} }
class EditLabelRoute extends GoRouteData { class EditLabelRoute extends GoRouteData {
static final GlobalKey<NavigatorState> $parentNavigatorKey = rootNavigatorKey; static final GlobalKey<NavigatorState> $parentNavigatorKey =
outerShellNavigatorKey;
final Label $extra; final Label $extra;
@@ -67,7 +68,8 @@ class EditLabelRoute extends GoRouteData {
} }
class CreateLabelRoute extends GoRouteData { class CreateLabelRoute extends GoRouteData {
static final GlobalKey<NavigatorState> $parentNavigatorKey = rootNavigatorKey; static final GlobalKey<NavigatorState> $parentNavigatorKey =
outerShellNavigatorKey;
final LabelType $extra; final LabelType $extra;
final String? name; final String? name;
@@ -88,7 +90,8 @@ class CreateLabelRoute extends GoRouteData {
} }
class LinkedDocumentsRoute extends GoRouteData { class LinkedDocumentsRoute extends GoRouteData {
static final GlobalKey<NavigatorState> $parentNavigatorKey = rootNavigatorKey; static final GlobalKey<NavigatorState> $parentNavigatorKey =
outerShellNavigatorKey;
final DocumentFilter $extra; final DocumentFilter $extra;
const LinkedDocumentsRoute(this.$extra); const LinkedDocumentsRoute(this.$extra);

View File

@@ -52,7 +52,8 @@ class ScannerRoute extends GoRouteData {
} }
class DocumentUploadRoute extends GoRouteData { class DocumentUploadRoute extends GoRouteData {
static final GlobalKey<NavigatorState> $parentNavigatorKey = rootNavigatorKey; static final GlobalKey<NavigatorState> $parentNavigatorKey =
outerShellNavigatorKey;
final FutureOr<Uint8List> $extra; final FutureOr<Uint8List> $extra;
final String? title; final String? title;
final String? filename; final String? filename;
@@ -72,6 +73,7 @@ class DocumentUploadRoute extends GoRouteData {
context.read(), context.read(),
context.read(), context.read(),
context.read(), context.read(),
context.read(),
), ),
child: DocumentUploadPreparationPage( child: DocumentUploadPreparationPage(
title: title, title: title,

View File

@@ -11,7 +11,8 @@ part 'upload_queue_route.g.dart';
name: R.uploadQueue, name: R.uploadQueue,
) )
class UploadQueueRoute extends GoRouteData { class UploadQueueRoute extends GoRouteData {
static final GlobalKey<NavigatorState> $parentNavigatorKey = rootNavigatorKey; static final GlobalKey<NavigatorState> $parentNavigatorKey =
outerShellNavigatorKey;
@override @override
Widget build(BuildContext context, GoRouterState state) { Widget build(BuildContext context, GoRouterState state) {

View File

@@ -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/core/factory/paperless_api_factory.dart';
import 'package:paperless_mobile/features/home/view/home_shell_widget.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/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:paperless_mobile/routes/navigation_keys.dart';
import 'package:provider/provider.dart'; import 'package:provider/provider.dart';
@@ -50,7 +50,7 @@ import 'package:provider/provider.dart';
// ) // )
class ProviderShellRoute extends ShellRouteData { class ProviderShellRoute extends ShellRouteData {
final PaperlessApiFactory apiFactory; final PaperlessApiFactory apiFactory;
static final GlobalKey<NavigatorState> $navigatorKey = rootNavigatorKey; static final GlobalKey<NavigatorState> $navigatorKey = outerShellNavigatorKey;
const ProviderShellRoute(this.apiFactory); const ProviderShellRoute(this.apiFactory);
@@ -77,7 +77,7 @@ class ProviderShellRoute extends ShellRouteData {
child: ChangeNotifierProvider( child: ChangeNotifierProvider(
create: (context) => ConsumptionChangeNotifier() create: (context) => ConsumptionChangeNotifier()
..loadFromConsumptionDirectory(userId: currentUserId), ..loadFromConsumptionDirectory(userId: currentUserId),
child: UploadQueueShell(child: navigator), child: EventListenerShell(child: navigator),
), ),
); );
} }

View File

@@ -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<AddAccountRoute>(
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<AuthenticationCubit>().addAccount(
credentials: LoginFormCredentials(
username: username,
password: password,
),
clientCertificate: clientCertificate,
serverUrl: serverUrl,
enableBiometricAuthentication: false,
locale: Localizations.localeOf(context).languageCode,
);
final shoudSwitch = await showDialog<bool>(
context: context,
builder: (context) => const SwitchAccountDialog(),
) ??
false;
if (shoudSwitch) {
await context.read<AuthenticationCubit>().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,
);
}
}

View File

@@ -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<CheckingLoginRoute>(
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..."),
),
);
}
}

View File

@@ -6,12 +6,12 @@ import 'package:paperless_mobile/routes/routes.dart';
part 'logging_out_route.g.dart'; part 'logging_out_route.g.dart';
@TypedGoRoute<LogginOutRoute>( @TypedGoRoute<LoggingOutRoute>(
path: "/logging-out", path: "/logging-out",
name: R.loggingOut, name: R.loggingOut,
) )
class LogginOutRoute extends GoRouteData { class LoggingOutRoute extends GoRouteData {
const LogginOutRoute(); const LoggingOutRoute();
@override @override
Widget build(BuildContext context, GoRouterState state) { Widget build(BuildContext context, GoRouterState state) {
return Scaffold( return Scaffold(

View File

@@ -1,10 +1,18 @@
import 'dart:async'; import 'dart:async';
import 'package:flutter/src/widgets/framework.dart'; import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:go_router/go_router.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/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_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'; import 'package:paperless_mobile/routes/routes.dart';
part 'login_route.g.dart'; part 'login_route.g.dart';
@@ -12,12 +20,51 @@ part 'login_route.g.dart';
@TypedGoRoute<LoginRoute>( @TypedGoRoute<LoginRoute>(
path: "/login", path: "/login",
name: R.login, name: R.login,
routes: [
TypedGoRoute<SwitchingAccountsRoute>(
path: "switching-account",
name: R.switchingAccount,
),
TypedGoRoute<AuthenticatingRoute>(
path: 'authenticating',
name: R.authenticating,
),
TypedGoRoute<VerifyIdentityRoute>(
path: 'verify-identity',
name: R.verifyIdentity,
),
TypedGoRoute<LoginToExistingAccountRoute>(
path: 'existing',
name: R.loginToExistingAccount,
),
TypedGoRoute<RestoringSessionRoute>(
path: 'restoring-session',
name: R.restoringSession,
),
],
) )
class LoginRoute extends GoRouteData { 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 @override
Widget build(BuildContext context, GoRouterState state) { Widget build(BuildContext context, GoRouterState state) {
return const LoginPage(); return LoginPage(
initialServerUrl: serverUrl,
initialUsername: username,
initialPassword: password,
initialClientCertificate: $extra,
);
} }
@override @override
@@ -28,3 +75,77 @@ class LoginRoute extends GoRouteData {
return null; 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<String?> 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);
}
}

View File

@@ -13,7 +13,7 @@ part 'settings_route.g.dart';
name: R.settings, name: R.settings,
) )
class SettingsRoute extends GoRouteData { class SettingsRoute extends GoRouteData {
static final GlobalKey<NavigatorState> $parentNavigatorKey = rootNavigatorKey; static final GlobalKey<NavigatorState> $parentNavigatorKey = outerShellNavigatorKey;
@override @override
Widget build(BuildContext context, GoRouterState state) { Widget build(BuildContext context, GoRouterState state) {

View File

@@ -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<SwitchingAccountsRoute>(
path: '/switching-accounts',
name: R.switchingAccounts,
)
class SwitchingAccountsRoute extends GoRouteData {
const SwitchingAccountsRoute();
@override
Widget build(BuildContext context, GoRouterState state) {
return const SwitchingAccountsPage();
}
}

View File

@@ -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<VerifyIdentityRoute>(
path: '/verify-identity',
name: R.verifyIdentity,
)
class VerifyIdentityRoute extends GoRouteData {
const VerifyIdentityRoute();
@override
Widget build(BuildContext context, GoRouterState state) {
return const VerifyIdentityPage();
}
}

View File

@@ -20,7 +20,8 @@ class PaperlessFormValidationException implements Exception {
} }
static bool canParse(Map<String, dynamic> json) { static bool canParse(Map<String, dynamic> json) {
return json.values.every((element) => element is String); return json.values
.every((element) => element is String || element is List);
} }
factory PaperlessFormValidationException.fromJson(Map<String, dynamic> json) { factory PaperlessFormValidationException.fromJson(Map<String, dynamic> json) {

View File

@@ -20,13 +20,15 @@ class PaperlessAuthenticationApiImpl implements PaperlessAuthenticationApi {
"password": password, "password": password,
}, },
options: Options( options: Options(
sendTimeout: const Duration(seconds: 5),
receiveTimeout: const Duration(seconds: 5),
followRedirects: false, followRedirects: false,
headers: { headers: {
"Accept": "application/json", "Accept": "application/json",
}, },
validateStatus: (status) { // validateStatus: (status) {
return status! == 200; // return status! == 200;
}, // },
), ),
); );
return response.data['token']; return response.data['token'];

View File

@@ -29,7 +29,7 @@ abstract class PaperlessDocumentsApi {
DocumentModel document, DocumentModel document,
String localFilePath, { String localFilePath, {
bool original = false, bool original = false,
void Function(double)? onProgressChanged, void Function(double progress)? onProgressChanged,
}); });
Future<FieldSuggestions> findSuggestions(DocumentModel document); Future<FieldSuggestions> findSuggestions(DocumentModel document);

Some files were not shown because too many files have changed in this diff Show More