diff --git a/lib/core/extensions/document_iterable_extensions.dart b/lib/core/extensions/document_extensions.dart similarity index 68% rename from lib/core/extensions/document_iterable_extensions.dart rename to lib/core/extensions/document_extensions.dart index 0bb9718..a767023 100644 --- a/lib/core/extensions/document_iterable_extensions.dart +++ b/lib/core/extensions/document_extensions.dart @@ -1,4 +1,6 @@ import 'package:collection/collection.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:paperless_api/paperless_api.dart'; extension DocumentModelIterableExtension on Iterable { @@ -16,3 +18,8 @@ extension DocumentModelIterableExtension on Iterable { return whereNot((element) => element.id == document.id); } } + +extension SessionAwareDownloadIdExtension on DocumentModel { + String buildThumbnailUrl(BuildContext context) => + context.read().getThumbnailUrl(id); +} diff --git a/lib/core/repository/label_repository.dart b/lib/core/repository/label_repository.dart index 4e068df..7c2d3ef 100644 --- a/lib/core/repository/label_repository.dart +++ b/lib/core/repository/label_repository.dart @@ -1,6 +1,5 @@ import 'dart:async'; -import 'package:flutter/widgets.dart'; import 'package:paperless_api/paperless_api.dart'; import 'package:paperless_mobile/core/repository/label_repository_state.dart'; import 'package:paperless_mobile/core/repository/persistent_repository.dart'; @@ -11,14 +10,12 @@ class LabelRepository extends PersistentRepository { LabelRepository(this._api) : super(const LabelRepositoryState()); Future initialize() async { - - await Future.wait([ - findAllCorrespondents(), - findAllDocumentTypes(), - findAllStoragePaths(), - findAllTags(), - ]); - + await Future.wait([ + findAllCorrespondents(), + findAllDocumentTypes(), + findAllStoragePaths(), + findAllTags(), + ]); } Future createTag(Tag object) async { diff --git a/lib/core/widgets/form_builder_fields/form_builder_localized_date_picker.dart b/lib/core/widgets/form_builder_fields/form_builder_localized_date_picker.dart index 44b5d89..2d25a92 100644 --- a/lib/core/widgets/form_builder_fields/form_builder_localized_date_picker.dart +++ b/lib/core/widgets/form_builder_fields/form_builder_localized_date_picker.dart @@ -1,3 +1,5 @@ +// ignore_for_file: invalid_use_of_protected_member + import 'dart:collection'; import 'package:collection/collection.dart'; @@ -9,6 +11,39 @@ import 'package:paperless_mobile/core/extensions/flutter_extensions.dart'; import 'package:paperless_mobile/features/landing/view/widgets/mime_types_pie_chart.dart'; import 'package:paperless_mobile/generated/l10n/app_localizations.dart'; +class FormDateTime { + final int? day; + final int? month; + final int? year; + + FormDateTime({this.day, this.month, this.year}); + + FormDateTime.fromDateTime(DateTime date) + : day = date.day, + month = date.month, + year = date.year; + + FormDateTime copyWith({int? day, int? month, int? year}) { + return FormDateTime( + day: day ?? this.day, + month: month ?? this.month, + year: year ?? this.year, + ); + } + + bool get isComplete => day != null && month != null && year != null; + + DateTime? toDateTime() { + if (day == null && month == null && year == null) { + return null; + } + if (!isComplete) { + throw ArgumentError.notNull("day, month and year must be set together"); + } + return DateTime(year!, month!, day!); + } +} + /// A localized, segmented date input field. class FormBuilderLocalizedDatePicker extends StatefulWidget { final String name; @@ -124,42 +159,35 @@ class _FormBuilderLocalizedDatePickerState } } }, - child: FormBuilderField( + child: FormBuilderField( + name: widget.name, validator: _validateDate, onChanged: (value) { - // We have to temporarily disable our listeners on the TextEditingController here - // since otherwise the listeners get notified of the change and - // the fields get focused and highlighted/selected (as defined in the - // listeners above). - _temporarilyDisableListeners = true; - for (var control in _textFieldControls) { - control.controller.text = DateFormat(control.format).format(value!); + assert(!widget.allowUnset && value != null); + if (value == null) { + return; } - _temporarilyDisableListeners = false; + // When the change is requested from external sources, such as calling + // field.didChange(value), then we want to update the text fields individually + // without causing the either field to gain focus (as defined above). + final isChangeRequestedFromOutside = + _textFieldControls.none((element) => element.node.hasFocus); + if (isChangeRequestedFromOutside) { + _updateInputsWithDate(value, disableListeners: true); + } + // Imitate the functionality of the validator function in "normal" form fields. + // The error is shown on the outer decorator as if this was a regular text input. + // Errors are cleared after the next user interaction. final error = _validateDate(value); setState(() { _error = error; }); - - if (value?.isBefore(widget.firstDate) ?? false) { - setState(() => _error = "Date must be after " + - DateFormat.yMd(widget.locale.toString()) - .format(widget.firstDate) + - "."); - return; - } - if (value?.isAfter(widget.lastDate) ?? false) { - setState(() => _error = "Date must be before " + - DateFormat.yMd(widget.locale.toString()) - .format(widget.lastDate) + - "."); - return; - } }, autovalidateMode: AutovalidateMode.onUserInteraction, - name: widget.name, - initialValue: widget.initialValue, + initialValue: widget.initialValue != null + ? FormDateTime.fromDateTime(widget.initialValue!) + : null, builder: (field) { return GestureDetector( onTap: () { @@ -170,7 +198,6 @@ class _FormBuilderLocalizedDatePickerState decoration: InputDecoration( errorText: _error, labelText: widget.labelText, - prefixIcon: widget.prefixIcon, suffixIcon: Row( mainAxisSize: MainAxisSize.min, children: [ @@ -179,28 +206,33 @@ class _FormBuilderLocalizedDatePickerState onPressed: () async { final selectedDate = await showDatePicker( context: context, - initialDate: widget.initialValue ?? DateTime.now(), + initialDate: + field.value?.toDateTime() ?? DateTime.now(), firstDate: widget.firstDate, lastDate: widget.lastDate, initialEntryMode: DatePickerEntryMode.calendarOnly, ); if (selectedDate != null) { - _updateInputsWithDate(selectedDate); - field.didChange(selectedDate); - FocusScope.of(context).unfocus(); + final formDate = + FormDateTime.fromDateTime(selectedDate); + _temporarilyDisableListeners = true; + _updateInputsWithDate(formDate); + field.didChange(formDate); + _temporarilyDisableListeners = false; } }, ), - IconButton( - onPressed: () { - for (var c in _textFieldControls) { - c.controller.clear(); - } - _textFieldControls.first.node.requestFocus(); - field.didChange(null); - }, - icon: const Icon(Icons.clear), - ), + if (widget.allowUnset) + IconButton( + onPressed: () { + for (var c in _textFieldControls) { + c.controller.clear(); + } + _textFieldControls.first.node.requestFocus(); + field.didChange(null); + }, + icon: const Icon(Icons.clear), + ), ], ).paddedOnly(right: 4), ), @@ -220,19 +252,26 @@ class _FormBuilderLocalizedDatePickerState ); } - String? _validateDate(DateTime? date) { + String? _validateDate(FormDateTime? date) { if (widget.allowUnset && date == null) { return null; } if (date == null) { return S.of(context)!.thisFieldIsRequired; } - if (date.isBefore(widget.firstDate)) { + final d = date.toDateTime(); + if (d == null) { + return S.of(context)!.thisFieldIsRequired; + } + if (d.day != date.day && d.month != date.month && d.year != date.year) { + return "Invalid date."; + } + if (d.isBefore(widget.firstDate)) { final formattedDateHint = DateFormat.yMd(widget.locale.toString()).format(widget.firstDate); return "Date must be after $formattedDateHint."; } - if (date.isAfter(widget.lastDate)) { + if (d.isAfter(widget.lastDate)) { final formattedDateHint = DateFormat.yMd(widget.locale.toString()).format(widget.lastDate); return "Date must be before $formattedDateHint."; @@ -240,30 +279,31 @@ class _FormBuilderLocalizedDatePickerState return null; } - void _updateInputsWithDate(DateTime date) { - final components = _format.split(_separator); - for (int i = 0; i < components.length; i++) { - final formatString = components[i]; - final value = DateFormat(formatString).format(date); - _textFieldControls.elementAt(i).controller.text = value; + void _updateInputsWithDate( + FormDateTime date, { + bool disableListeners = false, + }) { + if (disableListeners) { + _temporarilyDisableListeners = true; } + for (var controls in _textFieldControls) { + final value = DateFormat(controls.format).format(date.toDateTime()!); + controls.controller.text = value; + } + _temporarilyDisableListeners = false; } Widget _buildDateSegmentInput( _NeighbourAwareDateInputSegmentControls controls, BuildContext context, - FormFieldState field, + FormFieldState field, ) { return TextFormField( onFieldSubmitted: (value) { if (value.length < controls.format.length) { controls.controller.text = value.padLeft(controls.format.length, '0'); } - _textFieldControls - .elementAt(controls.position) - .next - ?.node - .requestFocus(); + controls.next?.node.requestFocus(); }, style: const TextStyle(fontFamily: 'RobotoMono'), keyboardType: TextInputType.datetime, @@ -275,17 +315,18 @@ class _FormBuilderLocalizedDatePickerState maxLengthEnforcement: MaxLengthEnforcement.enforced, enableInteractiveSelection: false, onChanged: (value) { - if (value.length == controls.format.length && field.value != null) { + if (value.length == controls.format.length) { final number = int.tryParse(value); if (number == null) { return; } + final fieldValue = field.value ?? FormDateTime(); final newValue = switch (controls.type) { - _DateInputSegment.day => field.value!.copyWith(day: number), - _DateInputSegment.month => field.value!.copyWith(month: number), - _DateInputSegment.year => field.value!.copyWith(year: number), + _DateInputSegment.day => fieldValue.copyWith(day: number), + _DateInputSegment.month => fieldValue.copyWith(month: number), + _DateInputSegment.year => fieldValue.copyWith(year: number), }; - field.didChange(newValue); + field.setValue(newValue); } }, inputFormatters: [ @@ -299,6 +340,12 @@ class _FormBuilderLocalizedDatePickerState }, ), ], + onEditingComplete: () { + if (field.value != null) { + _updateInputsWithDate(field.value!, disableListeners: true); + } + FocusScope.of(context).unfocus(); + }, decoration: InputDecoration( isDense: true, suffixIcon: controls.position < 2 diff --git a/lib/features/document_details/cubit/document_details_cubit.dart b/lib/features/document_details/cubit/document_details_cubit.dart index eae2199..4b38265 100644 --- a/lib/features/document_details/cubit/document_details_cubit.dart +++ b/lib/features/document_details/cubit/document_details_cubit.dart @@ -2,23 +2,23 @@ import 'dart:async'; import 'dart:io'; import 'package:bloc/bloc.dart'; +import 'package:cross_file/cross_file.dart'; import 'package:flutter/material.dart'; -import 'package:freezed_annotation/freezed_annotation.dart'; import 'package:open_filex/open_filex.dart'; import 'package:paperless_api/paperless_api.dart'; -import 'package:paperless_mobile/features/logging/data/logger.dart'; import 'package:paperless_mobile/core/notifier/document_changed_notifier.dart'; import 'package:paperless_mobile/core/repository/label_repository.dart'; import 'package:paperless_mobile/core/service/file_service.dart'; +import 'package:paperless_mobile/features/logging/data/logger.dart'; import 'package:paperless_mobile/features/notifications/services/local_notification_service.dart'; +import 'package:path/path.dart' as p; import 'package:printing/printing.dart'; import 'package:share_plus/share_plus.dart'; -import 'package:cross_file/cross_file.dart'; -import 'package:path/path.dart' as p; -part 'document_details_cubit.freezed.dart'; + part 'document_details_state.dart'; class DocumentDetailsCubit extends Cubit { + final int id; final PaperlessDocumentsApi _api; final DocumentChangedNotifier _notifier; final LocalNotificationService _notificationService; @@ -29,24 +29,46 @@ class DocumentDetailsCubit extends Cubit { this._labelRepository, this._notifier, this._notificationService, { - required DocumentModel initialDocument, - }) : super(DocumentDetailsState(document: initialDocument)) { + required this.id, + }) : super(const DocumentDetailsInitial()) { _notifier.addListener(this, onUpdated: (document) { - if (document.id == state.document.id) { - replace(document); + if (state is DocumentDetailsLoaded) { + final currentState = state as DocumentDetailsLoaded; + if (document.id == currentState.document.id) { + replace(document); + } } }); - _labelRepository.addListener( - this, - onChanged: (labels) => emit( - state.copyWith( - correspondents: labels.correspondents, - documentTypes: labels.documentTypes, - tags: labels.tags, - storagePaths: labels.storagePaths, - ), - ), - ); + } + + Future initialize() async { + debugPrint("Initialize called"); + emit(const DocumentDetailsLoading()); + try { + final (document, metaData) = await Future.wait([ + _api.find(id), + _api.getMetaData(id), + ]).then((value) => ( + value[0] as DocumentModel, + value[1] as DocumentMetaData, + )); + // final document = await _api.find(id); + // final metaData = await _api.getMetaData(id); + debugPrint("Document data loaded for $id"); + emit(DocumentDetailsLoaded( + document: document, + metaData: metaData, + )); + } catch (error, stackTrace) { + logger.fe( + "An error occurred while loading data for document $id.", + className: runtimeType.toString(), + methodName: 'initialize', + error: error, + stackTrace: stackTrace, + ); + emit(const DocumentDetailsError()); + } } Future delete(DocumentModel document) async { @@ -54,20 +76,6 @@ class DocumentDetailsCubit extends Cubit { _notifier.notifyDeleted(document); } - Future loadMetaData() async { - final metaData = await _api.getMetaData(state.document); - if (!isClosed) { - emit(state.copyWith(metaData: metaData)); - } - } - - Future loadFullContent() async { - await Future.delayed(const Duration(seconds: 5)); - final doc = await _api.find(state.document.id); - _notifier.notifyUpdated(doc); - emit(state.copyWith(isFullContentLoaded: true)); - } - Future assignAsn( DocumentModel document, { int? asn, @@ -87,11 +95,15 @@ class DocumentDetailsCubit extends Cubit { } Future openDocumentInSystemViewer() async { - final cacheDir = FileService.instance.temporaryDirectory; - if (state.metaData == null) { - await loadMetaData(); + final s = state; + if (s is! DocumentDetailsLoaded) { + throw Exception( + "Document cannot be opened in system viewer " + "if document information has not yet been loaded.", + ); } - final filePath = state.metaData!.mediaFilename.replaceAll("/", " "); + final cacheDir = FileService.instance.temporaryDirectory; + final filePath = s.metaData.mediaFilename.replaceAll("/", " "); final fileName = "${p.basenameWithoutExtension(filePath)}.pdf"; final file = File("${cacheDir.path}/$fileName"); @@ -99,7 +111,7 @@ class DocumentDetailsCubit extends Cubit { if (!file.existsSync()) { file.createSync(); await _api.downloadToFile( - state.document, + s.document, file.path, ); } @@ -110,7 +122,14 @@ class DocumentDetailsCubit extends Cubit { } void replace(DocumentModel document) { - emit(state.copyWith(document: document)); + final s = state; + if (s is! DocumentDetailsLoaded) { + return; + } + emit(DocumentDetailsLoaded( + document: document, + metaData: s.metaData, + )); } Future downloadDocument({ @@ -118,10 +137,12 @@ class DocumentDetailsCubit extends Cubit { required String locale, required String userId, }) async { - if (state.metaData == null) { - await loadMetaData(); + final s = state; + if (s is! DocumentDetailsLoaded) { + return; } String targetPath = _buildDownloadFilePath( + s.metaData, downloadOriginal, FileService.instance.downloadsDirectory, ); @@ -130,7 +151,7 @@ class DocumentDetailsCubit extends Cubit { await File(targetPath).create(); } else { await _notificationService.notifyDocumentDownload( - document: state.document, + document: s.document, filename: p.basename(targetPath), filePath: targetPath, finished: true, @@ -149,12 +170,12 @@ class DocumentDetailsCubit extends Cubit { // ); await _api.downloadToFile( - state.document, + s.document, targetPath, original: downloadOriginal, onProgressChanged: (progress) { _notificationService.notifyDocumentDownload( - document: state.document, + document: s.document, filename: p.basename(targetPath), filePath: targetPath, finished: true, @@ -165,26 +186,28 @@ class DocumentDetailsCubit extends Cubit { }, ); await _notificationService.notifyDocumentDownload( - document: state.document, + document: s.document, filename: p.basename(targetPath), filePath: targetPath, finished: true, locale: locale, userId: userId, ); - logger.fi("Document '${state.document.title}' saved to $targetPath."); + logger.fi("Document '${s.document.title}' saved to $targetPath."); } Future shareDocument({bool shareOriginal = false}) async { - if (state.metaData == null) { - await loadMetaData(); + final s = state; + if (s is! DocumentDetailsLoaded) { + return; } String filePath = _buildDownloadFilePath( + s.metaData, shareOriginal, FileService.instance.temporaryDirectory, ); await _api.downloadToFile( - state.document, + s.document, filePath, original: shareOriginal, ); @@ -192,23 +215,27 @@ class DocumentDetailsCubit extends Cubit { [ XFile( filePath, - name: state.document.originalFileName, + name: s.document.originalFileName, mimeType: "application/pdf", - lastModified: state.document.modified, + lastModified: s.document.modified, ), ], - subject: state.document.title, + subject: s.document.title, ); } Future printDocument() async { - if (state.metaData == null) { - await loadMetaData(); + final s = state; + if (s is! DocumentDetailsLoaded) { + return; } - final filePath = - _buildDownloadFilePath(false, FileService.instance.temporaryDirectory); + final filePath = _buildDownloadFilePath( + s.metaData, + false, + FileService.instance.temporaryDirectory, + ); await _api.downloadToFile( - state.document, + s.document, filePath, original: false, ); @@ -217,13 +244,14 @@ class DocumentDetailsCubit extends Cubit { throw Exception("An error occurred while downloading the document."); } Printing.layoutPdf( - name: state.document.title, + name: s.document.title, onLayout: (format) => file.readAsBytesSync(), ); } - String _buildDownloadFilePath(bool original, Directory dir) { - final normalizedPath = state.metaData!.mediaFilename.replaceAll("/", " "); + String _buildDownloadFilePath( + DocumentMetaData meta, bool original, Directory dir) { + final normalizedPath = meta.mediaFilename.replaceAll("/", " "); final extension = original ? p.extension(normalizedPath) : '.pdf'; return "${dir.path}/${p.basenameWithoutExtension(normalizedPath)}$extension"; } diff --git a/lib/features/document_details/cubit/document_details_state.dart b/lib/features/document_details/cubit/document_details_state.dart index d24a593..0d7bbcd 100644 --- a/lib/features/document_details/cubit/document_details_state.dart +++ b/lib/features/document_details/cubit/document_details_state.dart @@ -1,14 +1,41 @@ part of 'document_details_cubit.dart'; -@freezed -class DocumentDetailsState with _$DocumentDetailsState { - const factory DocumentDetailsState({ - required DocumentModel document, - DocumentMetaData? metaData, - @Default(false) bool isFullContentLoaded, - @Default({}) Map correspondents, - @Default({}) Map documentTypes, - @Default({}) Map tags, - @Default({}) Map storagePaths, - }) = _DocumentDetailsState; +sealed class DocumentDetailsState { + const DocumentDetailsState(); } + +class DocumentDetailsInitial extends DocumentDetailsState { + const DocumentDetailsInitial(); +} + +class DocumentDetailsLoading extends DocumentDetailsState { + const DocumentDetailsLoading(); +} + +class DocumentDetailsLoaded extends DocumentDetailsState { + final DocumentModel document; + final DocumentMetaData metaData; + + const DocumentDetailsLoaded({ + required this.document, + required this.metaData, + }); +} + +class DocumentDetailsError extends DocumentDetailsState { + const DocumentDetailsError(); +} + + +// @freezed +// class DocumentDetailsState with _$DocumentDetailsState { +// const factory DocumentDetailsState({ +// required DocumentModel document, +// DocumentMetaData? metaData, +// @Default(false) bool isFullContentLoaded, +// @Default({}) Map correspondents, +// @Default({}) Map documentTypes, +// @Default({}) Map tags, +// @Default({}) Map storagePaths, +// }) = _DocumentDetailsState; +// } diff --git a/lib/features/document_details/view/pages/document_details_page.dart b/lib/features/document_details/view/pages/document_details_page.dart index 9225d8c..fff3bbe 100644 --- a/lib/features/document_details/view/pages/document_details_page.dart +++ b/lib/features/document_details/view/pages/document_details_page.dart @@ -2,20 +2,18 @@ import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:go_router/go_router.dart'; import 'package:intl/date_symbol_data_local.dart'; -import 'package:intl/intl.dart'; import 'package:open_filex/open_filex.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/extensions/flutter_extensions.dart'; import 'package:paperless_mobile/core/translation/error_code_localization_mapper.dart'; import 'package:paperless_mobile/core/widgets/material/colored_tab_bar.dart'; -import 'package:paperless_mobile/core/extensions/flutter_extensions.dart'; import 'package:paperless_mobile/features/document_details/cubit/document_details_cubit.dart'; import 'package:paperless_mobile/features/document_details/view/widgets/document_content_widget.dart'; import 'package:paperless_mobile/features/document_details/view/widgets/document_download_button.dart'; import 'package:paperless_mobile/features/document_details/view/widgets/document_meta_data_widget.dart'; import 'package:paperless_mobile/features/document_details/view/widgets/document_overview_widget.dart'; -import 'package:paperless_mobile/features/document_details/view/widgets/document_permissions_widget.dart'; import 'package:paperless_mobile/features/document_details/view/widgets/document_share_button.dart'; import 'package:paperless_mobile/features/documents/view/widgets/delete_document_confirmation_dialog.dart'; import 'package:paperless_mobile/features/documents/view/widgets/document_preview.dart'; @@ -29,13 +27,21 @@ import 'package:paperless_mobile/routes/typed/shells/authenticated_route.dart'; import 'package:paperless_mobile/theme.dart'; class DocumentDetailsPage extends StatefulWidget { + final int id; + final String? title; final bool isLabelClickable; final String? titleAndContentQueryString; + final String? thumbnailUrl; + final String? heroTag; const DocumentDetailsPage({ Key? key, this.isLabelClickable = true, this.titleAndContentQueryString, + this.thumbnailUrl, + required this.id, + this.heroTag, + this.title, }) : super(key: key); @override @@ -57,152 +63,157 @@ class _DocumentDetailsPageState extends State { final hasMultiUserSupport = context.watch().hasMultiUserSupport; final tabLength = 4 + (hasMultiUserSupport && false ? 1 : 0); - final title = context.watch().state.document.title; return AnnotatedRegion( value: buildOverlayStyle( Theme.of(context), systemNavigationBarColor: Theme.of(context).bottomAppBarTheme.color, ), - child: WillPopScope( - onWillPop: () async { - Navigator.of(context) - .pop(context.read().state.document); - return false; - }, - child: DefaultTabController( - length: tabLength, - child: BlocListener( - listenWhen: (previous, current) => - !previous.isConnected && current.isConnected, - listener: (context, state) { - context.read().loadMetaData(); - }, + child: BlocBuilder( + builder: (context, state) { + return DefaultTabController( + length: tabLength, child: Scaffold( extendBodyBehindAppBar: false, floatingActionButtonLocation: FloatingActionButtonLocation.endDocked, - floatingActionButton: _buildEditButton(), + floatingActionButton: switch (state) { + DocumentDetailsLoaded(document: var document) => + _buildEditButton(document), + _ => null + }, bottomNavigationBar: _buildBottomAppBar(), body: NestedScrollView( headerSliverBuilder: (context, innerBoxIsScrolled) => [ SliverOverlapAbsorber( handle: NestedScrollView.sliverOverlapAbsorberHandleFor( context), - sliver: SliverAppBar( - title: Text(title), - leading: const BackButton(), - pinned: true, - forceElevated: innerBoxIsScrolled, - collapsedHeight: kToolbarHeight, - expandedHeight: 250.0, - flexibleSpace: FlexibleSpaceBar( - background: BlocBuilder( - builder: (context, state) { - return Hero( - tag: "thumb_${state.document.id}", - child: GestureDetector( - onTap: () { - DocumentPreviewRoute($extra: state.document) - .push(context); - }, - child: Stack( - alignment: Alignment.topCenter, - children: [ - Positioned.fill( - child: DocumentPreview( - enableHero: false, - document: state.document, - fit: BoxFit.cover, - alignment: Alignment.topCenter, - ), - ), - Positioned.fill( - child: DecoratedBox( - decoration: BoxDecoration( - gradient: LinearGradient( - stops: [0.2, 0.4], - colors: [ - Theme.of(context) - .colorScheme - .background - .withOpacity(0.6), - Theme.of(context) - .colorScheme - .background - .withOpacity(0.3), - ], - begin: Alignment.topCenter, - end: Alignment.bottomCenter, + sliver: + BlocBuilder( + builder: (context, state) { + final title = switch (state) { + DocumentDetailsLoaded(document: var document) => + document.title, + _ => widget.title ?? '', + }; + return SliverAppBar( + title: Text(title), + leading: const BackButton(), + pinned: true, + forceElevated: innerBoxIsScrolled, + collapsedHeight: kToolbarHeight, + expandedHeight: 250.0, + flexibleSpace: FlexibleSpaceBar( + background: Builder( + builder: (context) { + return Hero( + tag: widget.heroTag ?? "thumb_${widget.id}", + child: GestureDetector( + onTap: () { + DocumentPreviewRoute( + id: widget.id, + title: title, + ).push(context); + }, + child: Stack( + alignment: Alignment.topCenter, + children: [ + Positioned.fill( + child: DocumentPreview( + documentId: widget.id, + title: title, + enableHero: false, + fit: BoxFit.cover, + alignment: Alignment.topCenter, ), ), - ), + Positioned.fill( + child: DecoratedBox( + decoration: BoxDecoration( + gradient: LinearGradient( + stops: [0.2, 0.4], + colors: [ + Theme.of(context) + .colorScheme + .background + .withOpacity(0.6), + Theme.of(context) + .colorScheme + .background + .withOpacity(0.3), + ], + begin: Alignment.topCenter, + end: Alignment.bottomCenter, + ), + ), + ), + ), + ], ), - ], - ), - ), - ); - }, - ), - ), - bottom: ColoredTabBar( - tabBar: TabBar( - isScrollable: true, - tabs: [ - Tab( - child: Text( - S.of(context)!.overview, - style: TextStyle( - color: Theme.of(context) - .colorScheme - .onPrimaryContainer, - ), - ), + ), + ); + }, ), - Tab( - child: Text( - S.of(context)!.content, - style: TextStyle( - color: Theme.of(context) - .colorScheme - .onPrimaryContainer, - ), - ), - ), - Tab( - child: Text( - S.of(context)!.metaData, - style: TextStyle( - color: Theme.of(context) - .colorScheme - .onPrimaryContainer, - ), - ), - ), - Tab( - child: Text( - S.of(context)!.similarDocuments, - style: TextStyle( - color: Theme.of(context) - .colorScheme - .onPrimaryContainer, - ), - ), - ), - if (hasMultiUserSupport && false) - Tab( - child: Text( - "Permissions", - style: TextStyle( - color: Theme.of(context) - .colorScheme - .onPrimaryContainer, + ), + bottom: ColoredTabBar( + tabBar: TabBar( + isScrollable: true, + tabs: [ + Tab( + child: Text( + S.of(context)!.overview, + style: TextStyle( + color: Theme.of(context) + .colorScheme + .onPrimaryContainer, + ), ), ), - ), - ], - ), - ), + Tab( + child: Text( + S.of(context)!.content, + style: TextStyle( + color: Theme.of(context) + .colorScheme + .onPrimaryContainer, + ), + ), + ), + Tab( + child: Text( + S.of(context)!.metaData, + style: TextStyle( + color: Theme.of(context) + .colorScheme + .onPrimaryContainer, + ), + ), + ), + Tab( + child: Text( + S.of(context)!.similarDocuments, + style: TextStyle( + color: Theme.of(context) + .colorScheme + .onPrimaryContainer, + ), + ), + ), + // if (hasMultiUserSupport && false) + // Tab( + // child: Text( + // "Permissions", + // style: TextStyle( + // color: Theme.of(context) + // .colorScheme + // .onPrimaryContainer, + // ), + // ), + // ), + ], + ), + ), + ); + }, ), ), ], @@ -214,7 +225,7 @@ class _DocumentDetailsPageState extends State { context.read(), context.read(), context.read(), - documentId: state.document.id, + documentId: widget.id, ), child: Padding( padding: const EdgeInsets.symmetric( @@ -229,12 +240,19 @@ class _DocumentDetailsPageState extends State { handle: NestedScrollView .sliverOverlapAbsorberHandleFor(context), ), - DocumentOverviewWidget( - document: state.document, - itemSpacing: _itemSpacing, - queryString: - widget.titleAndContentQueryString, - ), + switch (state) { + DocumentDetailsLoaded( + document: var document + ) => + DocumentOverviewWidget( + document: document, + itemSpacing: _itemSpacing, + queryString: + widget.titleAndContentQueryString, + ), + DocumentDetailsError() => _buildErrorState(), + _ => _buildLoadingState(), + }, ], ), CustomScrollView( @@ -243,13 +261,18 @@ class _DocumentDetailsPageState extends State { handle: NestedScrollView .sliverOverlapAbsorberHandleFor(context), ), - DocumentContentWidget( - isFullContentLoaded: - state.isFullContentLoaded, - document: state.document, - queryString: - widget.titleAndContentQueryString, - ), + switch (state) { + DocumentDetailsLoaded( + document: var document + ) => + DocumentContentWidget( + document: document, + queryString: + widget.titleAndContentQueryString, + ), + DocumentDetailsError() => _buildErrorState(), + _ => _buildLoadingState(), + } ], ), CustomScrollView( @@ -258,10 +281,19 @@ class _DocumentDetailsPageState extends State { handle: NestedScrollView .sliverOverlapAbsorberHandleFor(context), ), - DocumentMetaDataWidget( - document: state.document, - itemSpacing: _itemSpacing, - ), + switch (state) { + DocumentDetailsLoaded( + document: var document, + metaData: var metaData, + ) => + DocumentMetaDataWidget( + document: document, + itemSpacing: _itemSpacing, + metaData: metaData, + ), + DocumentDetailsError() => _buildErrorState(), + _ => _buildLoadingState(), + }, ], ), CustomScrollView( @@ -277,20 +309,20 @@ class _DocumentDetailsPageState extends State { ), ], ), - if (hasMultiUserSupport && false) - CustomScrollView( - controller: _pagingScrollController, - slivers: [ - SliverOverlapInjector( - handle: NestedScrollView - .sliverOverlapAbsorberHandleFor( - context), - ), - DocumentPermissionsWidget( - document: state.document, - ), - ], - ), + // if (hasMultiUserSupport && false) + // CustomScrollView( + // controller: _pagingScrollController, + // slivers: [ + // SliverOverlapInjector( + // handle: NestedScrollView + // .sliverOverlapAbsorberHandleFor( + // context), + // ), + // DocumentPermissionsWidget( + // document: state.document, + // ), + // ], + // ), ], ), ), @@ -299,13 +331,13 @@ class _DocumentDetailsPageState extends State { ), ), ), - ), - ), + ); + }, ), ); } - Widget _buildEditButton() { + Widget _buildEditButton(DocumentModel document) { final currentUser = context.watch(); bool canEdit = context.watchInternetConnection && @@ -313,7 +345,6 @@ class _DocumentDetailsPageState extends State { if (!canEdit) { return const SizedBox.shrink(); } - final document = context.read().state.document; return Tooltip( message: S.of(context)!.editDocumentTooltip, preferBelow: false, @@ -326,60 +357,80 @@ class _DocumentDetailsPageState extends State { ); } + Widget _buildErrorState() { + return SliverToBoxAdapter( + child: Center( + child: Text("Could not load document."), + ), + ); + } + + Widget _buildLoadingState() { + return SliverFillRemaining( + child: Center( + child: CircularProgressIndicator(), + ), + ); + } + BlocBuilder _buildBottomAppBar() { return BlocBuilder( builder: (context, state) { + final currentUser = context.watch(); return BottomAppBar( - child: BlocBuilder( - builder: (context, connectivityState) { - final currentUser = context.watch(); - return Row( - mainAxisAlignment: MainAxisAlignment.start, - children: [ - ConnectivityAwareActionWrapper( - disabled: !currentUser.paperlessUser.canDeleteDocuments, - offlineBuilder: (context, child) { - return const IconButton( - icon: Icon(Icons.delete), - onPressed: null, - ).paddedSymmetrically(horizontal: 4); - }, - child: IconButton( - tooltip: S.of(context)!.deleteDocumentTooltip, - icon: const Icon(Icons.delete), - onPressed: () => _onDelete(state.document), - ).paddedSymmetrically(horizontal: 4), + child: Builder( + builder: (context) { + return switch (state) { + DocumentDetailsLoaded(document: var document) => Row( + mainAxisAlignment: MainAxisAlignment.start, + children: [ + ConnectivityAwareActionWrapper( + disabled: !currentUser.paperlessUser.canDeleteDocuments, + offlineBuilder: (context, child) { + return const IconButton( + icon: Icon(Icons.delete), + onPressed: null, + ).paddedSymmetrically(horizontal: 4); + }, + child: IconButton( + tooltip: S.of(context)!.deleteDocumentTooltip, + icon: const Icon(Icons.delete), + onPressed: () => _onDelete(document), + ).paddedSymmetrically(horizontal: 4), + ), + ConnectivityAwareActionWrapper( + offlineBuilder: (context, child) => + const DocumentDownloadButton( + document: null, + enabled: false, + ), + child: DocumentDownloadButton( + document: document, + ), + ), + ConnectivityAwareActionWrapper( + offlineBuilder: (context, child) => const IconButton( + icon: Icon(Icons.open_in_new), + onPressed: null, + ), + child: IconButton( + tooltip: S.of(context)!.openInSystemViewer, + icon: const Icon(Icons.open_in_new), + onPressed: _onOpenFileInSystemViewer, + ).paddedOnly(right: 4.0), + ), + DocumentShareButton(document: document), + IconButton( + tooltip: S.of(context)!.print, + onPressed: () => context + .read() + .printDocument(), + icon: const Icon(Icons.print), + ), + ], ), - ConnectivityAwareActionWrapper( - offlineBuilder: (context, child) => - const DocumentDownloadButton( - document: null, - enabled: false, - ), - child: DocumentDownloadButton( - document: state.document, - ), - ), - ConnectivityAwareActionWrapper( - offlineBuilder: (context, child) => const IconButton( - icon: Icon(Icons.open_in_new), - onPressed: null, - ), - child: IconButton( - tooltip: S.of(context)!.openInSystemViewer, - icon: const Icon(Icons.open_in_new), - onPressed: _onOpenFileInSystemViewer, - ).paddedOnly(right: 4.0), - ), - DocumentShareButton(document: state.document), - IconButton( - tooltip: S.of(context)!.print, - onPressed: () => - context.read().printDocument(), - icon: const Icon(Icons.print), - ), - ], - ); + _ => SizedBox.shrink(), + }; }, ), ); @@ -423,11 +474,4 @@ class _DocumentDetailsPageState extends State { } } } - - Future _onOpen(DocumentModel document) async { - DocumentPreviewRoute( - $extra: document, - title: document.title, - ).push(context); - } } diff --git a/lib/features/document_details/view/widgets/archive_serial_number_field.dart b/lib/features/document_details/view/widgets/archive_serial_number_field.dart index 3aeaef7..1f420f7 100644 --- a/lib/features/document_details/view/widgets/archive_serial_number_field.dart +++ b/lib/features/document_details/view/widgets/archive_serial_number_field.dart @@ -50,11 +50,16 @@ class _ArchiveSerialNumberFieldState extends State { context.watch().paperlessUser.canEditDocuments; return BlocListener( listenWhen: (previous, current) => + previous is DocumentDetailsLoaded && + current is DocumentDetailsLoaded && previous.document.archiveSerialNumber != - current.document.archiveSerialNumber, + current.document.archiveSerialNumber, listener: (context, state) { - _asnEditingController.text = - state.document.archiveSerialNumber?.toString() ?? ''; + _asnEditingController.text = (state as DocumentDetailsLoaded) + .document + .archiveSerialNumber + ?.toString() ?? + ''; setState(() { _canUpdate = false; }); diff --git a/lib/features/document_details/view/widgets/document_content_widget.dart b/lib/features/document_details/view/widgets/document_content_widget.dart index a73dec3..b3c5be7 100644 --- a/lib/features/document_details/view/widgets/document_content_widget.dart +++ b/lib/features/document_details/view/widgets/document_content_widget.dart @@ -1,26 +1,37 @@ import 'package:flutter/material.dart'; -import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:paperless_api/paperless_api.dart'; import 'package:paperless_mobile/core/widgets/highlighted_text.dart'; import 'package:paperless_mobile/core/widgets/shimmer_placeholder.dart'; -import 'package:paperless_mobile/core/extensions/flutter_extensions.dart'; -import 'package:paperless_mobile/features/document_details/cubit/document_details_cubit.dart'; -import 'package:paperless_mobile/generated/l10n/app_localizations.dart'; class DocumentContentWidget extends StatelessWidget { - final bool isFullContentLoaded; - final String? queryString; final DocumentModel document; + final String? queryString; const DocumentContentWidget({ super.key, - required this.isFullContentLoaded, required this.document, this.queryString, }); @override Widget build(BuildContext context) { - final screenWidth = MediaQuery.sizeOf(context).width; + // if (document == null) { + // final widths = [.3, .8, .9, .7, .6, .4, .8, .8, .6, .4]; + // return SliverToBoxAdapter( + // child: ShimmerPlaceholder( + // child: Column( + // children: [ + // for (int i = 0; i < 10; i++) + // Container( + // width: MediaQuery.sizeOf(context).width * widths[i], + // height: 14, + // color: Colors.white, + // margin: EdgeInsets.symmetric(vertical: 4), + // ), + // ], + // ), + // ), + // ); + // } return SliverToBoxAdapter( child: Column( crossAxisAlignment: CrossAxisAlignment.start, @@ -31,21 +42,6 @@ class DocumentContentWidget extends StatelessWidget { style: Theme.of(context).textTheme.bodyMedium, caseSensitive: false, ), - if (!isFullContentLoaded) - ShimmerPlaceholder( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - for (var scale in [0.5, 0.9, 0.5, 0.8, 0.9, 0.9]) - Container( - margin: const EdgeInsets.symmetric(vertical: 4), - width: screenWidth * scale, - height: 14, - color: Colors.white, - ), - ], - ), - ).paddedOnly(top: 4), ], ), ); diff --git a/lib/features/document_details/view/widgets/document_meta_data_widget.dart b/lib/features/document_details/view/widgets/document_meta_data_widget.dart index 0d003ff..fb55bd7 100644 --- a/lib/features/document_details/view/widgets/document_meta_data_widget.dart +++ b/lib/features/document_details/view/widgets/document_meta_data_widget.dart @@ -4,87 +4,73 @@ import 'package:intl/intl.dart'; import 'package:paperless_api/paperless_api.dart'; import 'package:paperless_mobile/core/database/tables/local_user_account.dart'; import 'package:paperless_mobile/core/extensions/flutter_extensions.dart'; -import 'package:paperless_mobile/features/document_details/cubit/document_details_cubit.dart'; import 'package:paperless_mobile/features/document_details/view/widgets/archive_serial_number_field.dart'; import 'package:paperless_mobile/features/document_details/view/widgets/details_item.dart'; import 'package:paperless_mobile/generated/l10n/app_localizations.dart'; import 'package:paperless_mobile/helpers/format_helpers.dart'; -class DocumentMetaDataWidget extends StatefulWidget { +class DocumentMetaDataWidget extends StatelessWidget { final DocumentModel document; + final DocumentMetaData metaData; final double itemSpacing; const DocumentMetaDataWidget({ super.key, required this.document, + required this.metaData, required this.itemSpacing, }); - @override - State createState() => _DocumentMetaDataWidgetState(); -} - -class _DocumentMetaDataWidgetState extends State { @override Widget build(BuildContext context) { final currentUser = context.watch().paperlessUser; - return BlocBuilder( - builder: (context, state) { - if (state.metaData == null) { - return const SliverToBoxAdapter( - child: Center( - child: CircularProgressIndicator(), - ), - ); - } - return SliverList( - delegate: SliverChildListDelegate( - [ - if (currentUser.canEditDocuments) - ArchiveSerialNumberField( - document: widget.document, - ).paddedOnly(bottom: widget.itemSpacing), - DetailsItem.text( - DateFormat.yMMMMd(Localizations.localeOf(context).toString()) - .format(widget.document.modified), - context: context, - label: S.of(context)!.modifiedAt, - ).paddedOnly(bottom: widget.itemSpacing), - DetailsItem.text( - DateFormat.yMMMMd(Localizations.localeOf(context).toString()) - .format(widget.document.added), - context: context, - label: S.of(context)!.addedAt, - ).paddedOnly(bottom: widget.itemSpacing), - DetailsItem.text( - state.metaData!.mediaFilename, - context: context, - label: S.of(context)!.mediaFilename, - ).paddedOnly(bottom: widget.itemSpacing), - if (state.document.originalFileName != null) - DetailsItem.text( - state.document.originalFileName!, - context: context, - label: S.of(context)!.originalMD5Checksum, - ).paddedOnly(bottom: widget.itemSpacing), - DetailsItem.text( - state.metaData!.originalChecksum, - context: context, - label: S.of(context)!.originalMD5Checksum, - ).paddedOnly(bottom: widget.itemSpacing), - DetailsItem.text( - formatBytes(state.metaData!.originalSize, 2), - context: context, - label: S.of(context)!.originalFileSize, - ).paddedOnly(bottom: widget.itemSpacing), - DetailsItem.text( - state.metaData!.originalMimeType, - context: context, - label: S.of(context)!.originalMIMEType, - ).paddedOnly(bottom: widget.itemSpacing), - ], - ), - ); - }, + + return SliverList( + delegate: SliverChildListDelegate( + [ + if (currentUser.canEditDocuments) + ArchiveSerialNumberField( + document: document, + ).paddedOnly(bottom: itemSpacing), + DetailsItem.text( + DateFormat.yMMMMd(Localizations.localeOf(context).toString()) + .format(document.modified), + context: context, + label: S.of(context)!.modifiedAt, + ).paddedOnly(bottom: itemSpacing), + DetailsItem.text( + DateFormat.yMMMMd(Localizations.localeOf(context).toString()) + .format(document.added), + context: context, + label: S.of(context)!.addedAt, + ).paddedOnly(bottom: itemSpacing), + DetailsItem.text( + metaData.mediaFilename, + context: context, + label: S.of(context)!.mediaFilename, + ).paddedOnly(bottom: itemSpacing), + if (document.originalFileName != null) + DetailsItem.text( + document.originalFileName!, + context: context, + label: S.of(context)!.originalMD5Checksum, + ).paddedOnly(bottom: itemSpacing), + DetailsItem.text( + metaData.originalChecksum, + context: context, + label: S.of(context)!.originalMD5Checksum, + ).paddedOnly(bottom: itemSpacing), + DetailsItem.text( + formatBytes(metaData.originalSize, 2), + context: context, + label: S.of(context)!.originalFileSize, + ).paddedOnly(bottom: itemSpacing), + DetailsItem.text( + metaData.originalMimeType, + context: context, + label: S.of(context)!.originalMIMEType, + ).paddedOnly(bottom: itemSpacing), + ], + ), ); } } diff --git a/lib/features/document_details/view/widgets/document_overview_widget.dart b/lib/features/document_details/view/widgets/document_overview_widget.dart index d15d0c8..c6d255d 100644 --- a/lib/features/document_details/view/widgets/document_overview_widget.dart +++ b/lib/features/document_details/view/widgets/document_overview_widget.dart @@ -6,6 +6,7 @@ import 'package:paperless_mobile/core/database/tables/local_user_account.dart'; import 'package:paperless_mobile/core/repository/label_repository.dart'; import 'package:paperless_mobile/core/widgets/highlighted_text.dart'; import 'package:paperless_mobile/core/extensions/flutter_extensions.dart'; +import 'package:paperless_mobile/core/widgets/shimmer_placeholder.dart'; import 'package:paperless_mobile/features/document_details/view/widgets/details_item.dart'; import 'package:paperless_mobile/features/labels/tags/view/widgets/tags_widget.dart'; import 'package:paperless_mobile/features/labels/view/widgets/label_text.dart'; @@ -27,6 +28,7 @@ class DocumentOverviewWidget extends StatelessWidget { Widget build(BuildContext context) { final user = context.watch().paperlessUser; final availableLabels = context.watch().state; + return SliverList.list( children: [ if (document.title.isNotEmpty) diff --git a/lib/features/document_edit/cubit/document_edit_cubit.dart b/lib/features/document_edit/cubit/document_edit_cubit.dart index d6f28d2..850f41a 100644 --- a/lib/features/document_edit/cubit/document_edit_cubit.dart +++ b/lib/features/document_edit/cubit/document_edit_cubit.dart @@ -27,22 +27,6 @@ class DocumentEditCubit extends Cubit { emit(state.copyWith(document: doc)); } }); - _labelRepository.addListener( - this, - onChanged: (labels) { - if (isClosed) { - return; - } - emit( - state.copyWith( - correspondents: labels.correspondents, - documentTypes: labels.documentTypes, - storagePaths: labels.storagePaths, - tags: labels.tags, - ), - ); - }, - ); } Future updateDocument(DocumentModel document) async { @@ -76,7 +60,6 @@ class DocumentEditCubit extends Cubit { @override Future close() { _notifier.removeListener(this); - _labelRepository.removeListener(this); return super.close(); } } diff --git a/lib/features/document_edit/cubit/document_edit_state.dart b/lib/features/document_edit/cubit/document_edit_state.dart index 0f1bb39..bf3d4c2 100644 --- a/lib/features/document_edit/cubit/document_edit_state.dart +++ b/lib/features/document_edit/cubit/document_edit_state.dart @@ -5,9 +5,5 @@ class DocumentEditState with _$DocumentEditState { const factory DocumentEditState({ required DocumentModel document, FieldSuggestions? suggestions, - @Default({}) Map correspondents, - @Default({}) Map documentTypes, - @Default({}) Map storagePaths, - @Default({}) Map tags, }) = _DocumentEditState; } diff --git a/lib/features/document_edit/view/document_edit_page.dart b/lib/features/document_edit/view/document_edit_page.dart index c1205c4..2acf274 100644 --- a/lib/features/document_edit/view/document_edit_page.dart +++ b/lib/features/document_edit/view/document_edit_page.dart @@ -9,6 +9,7 @@ import 'package:go_router/go_router.dart'; import 'package:intl/intl.dart'; import 'package:paperless_api/paperless_api.dart'; import 'package:paperless_mobile/core/database/tables/local_user_account.dart'; +import 'package:paperless_mobile/core/repository/label_repository.dart'; import 'package:paperless_mobile/core/widgets/dialog_utils/dialog_cancel_button.dart'; import 'package:paperless_mobile/core/widgets/dialog_utils/pop_with_unsaved_changes.dart'; import 'package:paperless_mobile/core/widgets/form_builder_fields/form_builder_localized_date_picker.dart'; @@ -45,39 +46,7 @@ class _DocumentEditPageState extends State { @override Widget build(BuildContext context) { final currentUser = context.watch().paperlessUser; - return BlocConsumer( - listenWhen: (previous, current) => - previous.document.content != current.document.content, - listener: (context, state) { - final contentField = _formKey.currentState?.fields[fkContent]; - if (contentField == null) { - return; - } - if (contentField.isDirty) { - showDialog( - context: context, - builder: (context) => AlertDialog( - //TODO: INTL - title: Text("Content has changed!"), - content: Text( - "The content of this document has changed. This can happen if the full content was not yet loaded. By accepting the incoming changes, your changes will be overwritten and therefore lost! Do you want to discard your changes in favor of the full content?", - ), - actions: [ - DialogCancelButton(), - ElevatedButton( - onPressed: () { - contentField.didChange(state.document.content); - Navigator.of(context).pop(); - }, - child: Text(S.of(context)!.discard), - ), - ], - ), - ); - } else { - contentField.didChange(state.document.content); - } - }, + return BlocBuilder( builder: (context, state) { final filteredSuggestions = state.suggestions; return PopWithUnsavedChanges( @@ -160,7 +129,7 @@ class _DocumentEditPageState extends State { S.of(context)!.addCorrespondent, labelText: S.of(context)!.correspondent, options: context - .watch() + .watch() .state .correspondents, initialValue: state @@ -203,7 +172,10 @@ class _DocumentEditPageState extends State { ? SetIdQueryParameter( id: state.document.documentType!) : const UnsetIdQueryParameter(), - options: state.documentTypes, + options: context + .watch() + .state + .documentTypes, name: _DocumentEditPageState.fkDocumentType, prefixIcon: const Icon(Icons.description_outlined), @@ -230,7 +202,10 @@ class _DocumentEditPageState extends State { currentUser.canCreateStoragePaths, addLabelText: S.of(context)!.addStoragePath, labelText: S.of(context)!.storagePath, - options: state.storagePaths, + options: context + .watch() + .state + .storagePaths, initialValue: state.document.storagePath != null ? SetIdQueryParameter( @@ -246,7 +221,8 @@ class _DocumentEditPageState extends State { // Tag form field if (currentUser.canViewTags) TagsFormField( - options: state.tags, + options: + context.watch().state.tags, name: fkTags, allowOnlySelection: true, allowCreation: true, @@ -290,30 +266,6 @@ class _DocumentEditPageState extends State { ); } - bool _isFieldDirty(DocumentModel document) { - final fkState = _formKey.currentState; - if (fkState == null) { - return false; - } - fkState.save(); - final ( - title, - correspondent, - documentType, - storagePath, - tags, - createdAt, - content - ) = _currentValues; - return document.title != title || - document.correspondent != correspondent || - document.documentType != documentType || - document.storagePath != storagePath || - const UnorderedIterableEquality().equals(document.tags, tags) || - document.created != createdAt || - document.content != content; - } - ( String? title, int? correspondent, @@ -333,7 +285,7 @@ class _DocumentEditPageState extends State { fkState.getRawValue(fkStoragePath); final tagsParam = fkState.getRawValue(fkTags); final title = fkState.getRawValue(fkTitle); - final created = fkState.getRawValue(fkCreatedDate); + final created = fkState.getRawValue(fkCreatedDate); final correspondent = switch (correspondentParam) { SetIdQueryParameter(id: var id) => id, _ => null, @@ -358,7 +310,7 @@ class _DocumentEditPageState extends State { documentType, storagePath, tags, - created, + created?.toDateTime(), content ); } @@ -432,7 +384,7 @@ class _DocumentEditPageState extends State { DateFormat.yMMMMd(Localizations.localeOf(context).toString()) .format(itemData)), onPressed: () => _formKey.currentState?.fields[fkCreatedDate] - ?.didChange(itemData), + ?.didChange(FormDateTime.fromDateTime(itemData)), ), ), ], diff --git a/lib/features/document_search/view/document_search_page.dart b/lib/features/document_search/view/document_search_page.dart index 5f8c6bb..83b96dc 100644 --- a/lib/features/document_search/view/document_search_page.dart +++ b/lib/features/document_search/view/document_search_page.dart @@ -4,6 +4,7 @@ import 'dart:math' as math; import 'package:collection/collection.dart'; import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:paperless_mobile/core/extensions/document_extensions.dart'; import 'package:paperless_mobile/core/extensions/flutter_extensions.dart'; import 'package:paperless_mobile/features/document_search/cubit/document_search_cubit.dart'; import 'package:paperless_mobile/features/document_search/view/remove_history_entry_dialog.dart'; @@ -219,8 +220,12 @@ class _DocumentSearchPageState extends State { hasLoaded: state.hasLoaded, enableHeroAnimation: false, onTap: (document) { - DocumentDetailsRoute($extra: document, isLabelClickable: false) - .push(context); + DocumentDetailsRoute( + title: document.title, + id: document.id, + isLabelClickable: false, + thumbnailUrl: document.buildThumbnailUrl(context), + ).push(context); }, ) ], diff --git a/lib/features/document_upload/cubit/document_upload_cubit.dart b/lib/features/document_upload/cubit/document_upload_cubit.dart index bfe4f50..5bc09e3 100644 --- a/lib/features/document_upload/cubit/document_upload_cubit.dart +++ b/lib/features/document_upload/cubit/document_upload_cubit.dart @@ -1,7 +1,6 @@ import 'dart:async'; import 'package:bloc/bloc.dart'; -import 'package:equatable/equatable.dart'; import 'package:flutter/foundation.dart'; import 'package:paperless_api/paperless_api.dart'; import 'package:paperless_mobile/core/repository/label_repository.dart'; @@ -21,18 +20,7 @@ class DocumentUploadCubit extends Cubit { this._documentApi, this._connectivityStatusService, this._tasksNotifier, - ) : super(const DocumentUploadState()) { - _labelRepository.addListener( - this, - onChanged: (labels) { - emit(state.copyWith( - correspondents: labels.correspondents, - documentTypes: labels.documentTypes, - tags: labels.tags, - )); - }, - ); - } + ) : super(const DocumentUploadState()); Future upload( Uint8List bytes, { @@ -44,7 +32,6 @@ class DocumentUploadCubit extends Cubit { Iterable tags = const [], DateTime? createdAt, int? asn, - void Function(double)? onProgressChanged, }) async { final taskId = await _documentApi.create( bytes, @@ -55,17 +42,15 @@ class DocumentUploadCubit extends Cubit { tags: tags, createdAt: createdAt, asn: asn, - onProgressChanged: onProgressChanged, + onProgressChanged: (progress) { + if (!isClosed) { + emit(state.copyWith(uploadProgress: progress)); + } + }, ); if (taskId != null) { _tasksNotifier.listenToTaskChanges(taskId); } return taskId; } - - @override - Future close() async { - _labelRepository.removeListener(this); - return super.close(); - } } diff --git a/lib/features/document_upload/cubit/document_upload_state.dart b/lib/features/document_upload/cubit/document_upload_state.dart index 61b7fa5..15cab11 100644 --- a/lib/features/document_upload/cubit/document_upload_state.dart +++ b/lib/features/document_upload/cubit/document_upload_state.dart @@ -1,33 +1,17 @@ part of 'document_upload_cubit.dart'; @immutable -class DocumentUploadState extends Equatable { - final Map tags; - final Map correspondents; - final Map documentTypes; - +class DocumentUploadState { + final double? uploadProgress; const DocumentUploadState({ - this.tags = const {}, - this.correspondents = const {}, - this.documentTypes = const {}, + this.uploadProgress, }); - @override - List get props => [ - tags, - correspondents, - documentTypes, - ]; - DocumentUploadState copyWith({ - Map? tags, - Map? correspondents, - Map? documentTypes, + double? uploadProgress, }) { return DocumentUploadState( - tags: tags ?? this.tags, - correspondents: correspondents ?? this.correspondents, - documentTypes: documentTypes ?? this.documentTypes, + uploadProgress: uploadProgress ?? this.uploadProgress, ); } } diff --git a/lib/features/document_upload/view/document_upload_preparation_page.dart b/lib/features/document_upload/view/document_upload_preparation_page.dart index 959d914..07f4c41 100644 --- a/lib/features/document_upload/view/document_upload_preparation_page.dart +++ b/lib/features/document_upload/view/document_upload_preparation_page.dart @@ -6,28 +6,24 @@ import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:flutter_form_builder/flutter_form_builder.dart'; import 'package:go_router/go_router.dart'; import 'package:hive/hive.dart'; -import 'package:intl/date_symbol_data_local.dart'; import 'package:intl/intl.dart'; import 'package:paperless_api/paperless_api.dart'; import 'package:paperless_mobile/core/database/hive/hive_config.dart'; import 'package:paperless_mobile/core/database/tables/global_settings.dart'; import 'package:paperless_mobile/core/database/tables/local_user_account.dart'; -import 'package:paperless_mobile/features/logging/data/logger.dart'; -import 'package:paperless_mobile/core/repository/label_repository.dart'; -import 'package:paperless_mobile/core/widgets/future_or_builder.dart'; import 'package:paperless_mobile/core/extensions/flutter_extensions.dart'; +import 'package:paperless_mobile/core/repository/label_repository.dart'; +import 'package:paperless_mobile/core/widgets/form_builder_fields/form_builder_localized_date_picker.dart'; +import 'package:paperless_mobile/core/widgets/future_or_builder.dart'; import 'package:paperless_mobile/features/document_upload/cubit/document_upload_cubit.dart'; -import 'package:paperless_mobile/features/edit_label/view/impl/add_correspondent_page.dart'; -import 'package:paperless_mobile/features/edit_label/view/impl/add_document_type_page.dart'; -import 'package:paperless_mobile/features/home/view/model/api_version.dart'; import 'package:paperless_mobile/features/labels/tags/view/widgets/tags_form_field.dart'; import 'package:paperless_mobile/features/labels/view/widgets/label_form_field.dart'; +import 'package:paperless_mobile/features/logging/data/logger.dart'; import 'package:paperless_mobile/features/sharing/view/widgets/file_thumbnail.dart'; import 'package:paperless_mobile/generated/l10n/app_localizations.dart'; import 'package:paperless_mobile/helpers/message_helpers.dart'; import 'package:paperless_mobile/routes/typed/branches/labels_route.dart'; import 'package:paperless_mobile/routes/typed/shells/authenticated_route.dart'; -import 'package:provider/provider.dart'; class DocumentUploadResult { final bool success; @@ -62,7 +58,6 @@ class _DocumentUploadPreparationPageState final GlobalKey _formKey = GlobalKey(); Map _errors = {}; - bool _isUploadLoading = false; late bool _syncTitleAndFilename; bool _showDatePickerDeleteIcon = false; final _now = DateTime.now(); @@ -75,21 +70,32 @@ class _DocumentUploadPreparationPageState @override Widget build(BuildContext context) { - return Scaffold( - extendBodyBehindAppBar: false, - resizeToAvoidBottomInset: true, - floatingActionButton: Visibility( - visible: MediaQuery.of(context).viewInsets.bottom == 0, - child: FloatingActionButton.extended( - heroTag: "fab_document_upload", - onPressed: _onSubmit, - label: Text(S.of(context)!.upload), - icon: const Icon(Icons.upload), - ), - ), - body: BlocBuilder( - builder: (context, state) { - return FormBuilder( + final labels = context.watch().state; + return BlocBuilder( + builder: (context, state) { + return Scaffold( + extendBodyBehindAppBar: false, + resizeToAvoidBottomInset: true, + floatingActionButton: Visibility( + visible: MediaQuery.of(context).viewInsets.bottom == 0, + child: FloatingActionButton.extended( + heroTag: "fab_document_upload", + onPressed: state.uploadProgress == null ? _onSubmit : null, + label: state.uploadProgress == null + ? Text(S.of(context)!.upload) + : Text("Uploading..."), //TODO: INTL + icon: state.uploadProgress == null + ? const Icon(Icons.upload) + : SizedBox( + height: 24, + width: 24, + child: CircularProgressIndicator( + strokeWidth: 3, + value: state.uploadProgress, + )).padded(4), + ), + ), + body: FormBuilder( key: _formKey, child: NestedScrollView( headerSliverBuilder: (context, innerBoxIsScrolled) => [ @@ -97,7 +103,7 @@ class _DocumentUploadPreparationPageState handle: NestedScrollView.sliverOverlapAbsorberHandleFor(context), sliver: SliverAppBar( - leading: BackButton(), + leading: const BackButton(), pinned: true, expandedHeight: 150, flexibleSpace: FlexibleSpaceBar( @@ -105,7 +111,7 @@ class _DocumentUploadPreparationPageState future: widget.fileBytes, builder: (context, snapshot) { if (!snapshot.hasData) { - return SizedBox.shrink(); + return const SizedBox.shrink(); } return FileThumbnail( bytes: snapshot.data!, @@ -117,12 +123,6 @@ class _DocumentUploadPreparationPageState title: Text(S.of(context)!.prepareDocument), collapseMode: CollapseMode.pin, ), - bottom: _isUploadLoading - ? PreferredSize( - child: LinearProgressIndicator(), - preferredSize: Size.fromHeight(4.0), - ) - : null, ), ), ], @@ -219,32 +219,13 @@ class _DocumentUploadPreparationPageState ), ), // Created at - FormBuilderDateTimePicker( - autovalidateMode: AutovalidateMode.always, - format: DateFormat.yMMMMd( - Localizations.localeOf(context).toString()), - inputType: InputType.date, + FormBuilderLocalizedDatePicker( name: DocumentModel.createdKey, - initialValue: null, - onChanged: (value) { - setState(() => - _showDatePickerDeleteIcon = value != null); - }, - decoration: InputDecoration( - prefixIcon: - const Icon(Icons.calendar_month_outlined), - labelText: S.of(context)!.createdAt + " *", - suffixIcon: _showDatePickerDeleteIcon - ? IconButton( - icon: const Icon(Icons.close), - onPressed: () { - _formKey.currentState! - .fields[DocumentModel.createdKey] - ?.didChange(null); - }, - ) - : null, - ), + firstDate: DateTime(1970, 1, 1), + lastDate: DateTime.now(), + locale: Localizations.localeOf(context), + labelText: S.of(context)!.createdAt + " *", + allowUnset: true, ), // Correspondent if (context @@ -261,7 +242,7 @@ class _DocumentUploadPreparationPageState addLabelText: S.of(context)!.addCorrespondent, labelText: S.of(context)!.correspondent + " *", name: DocumentModel.correspondentKey, - options: state.correspondents, + options: labels.correspondents, prefixIcon: const Icon(Icons.person_outline), allowSelectUnassigned: true, canCreateNewLabel: context @@ -284,7 +265,7 @@ class _DocumentUploadPreparationPageState addLabelText: S.of(context)!.addDocumentType, labelText: S.of(context)!.documentType + " *", name: DocumentModel.documentTypeKey, - options: state.documentTypes, + options: labels.documentTypes, prefixIcon: const Icon(Icons.description_outlined), allowSelectUnassigned: true, @@ -302,7 +283,7 @@ class _DocumentUploadPreparationPageState allowCreation: true, allowExclude: false, allowOnlySelection: true, - options: state.tags, + options: labels.tags, ), Text( "* " + S.of(context)!.uploadInferValuesHint, @@ -318,9 +299,9 @@ class _DocumentUploadPreparationPageState ), ), ), - ); - }, - ), + ), + ); + }, ); } @@ -328,7 +309,6 @@ class _DocumentUploadPreparationPageState if (_formKey.currentState?.saveAndValidate() ?? false) { final cubit = context.read(); try { - setState(() => _isUploadLoading = true); final formValues = _formKey.currentState!.value; final correspondentParam = @@ -336,7 +316,7 @@ class _DocumentUploadPreparationPageState final docTypeParam = formValues[DocumentModel.documentTypeKey] as IdQueryParameter?; final tagsParam = formValues[DocumentModel.tagsKey] as TagsQuery?; - final createdAt = formValues[DocumentModel.createdKey] as DateTime?; + final createdAt = formValues[DocumentModel.createdKey] as FormDateTime?; final title = formValues[DocumentModel.titleKey] as String; final correspondent = switch (correspondentParam) { SetIdQueryParameter(id: var id) => id, @@ -365,7 +345,7 @@ class _DocumentUploadPreparationPageState documentType: docType, correspondent: correspondent, tags: tags, - createdAt: createdAt, + createdAt: createdAt?.toDateTime(), asn: asn, ); showSnackBar( @@ -390,10 +370,6 @@ class _DocumentUploadPreparationPageState const PaperlessApiException.unknown(), stackTrace, ); - } finally { - setState(() { - _isUploadLoading = false; - }); } } } diff --git a/lib/features/documents/cubit/documents_cubit.dart b/lib/features/documents/cubit/documents_cubit.dart index 3cf8716..3103b57 100644 --- a/lib/features/documents/cubit/documents_cubit.dart +++ b/lib/features/documents/cubit/documents_cubit.dart @@ -5,7 +5,7 @@ import 'package:hydrated_bloc/hydrated_bloc.dart'; import 'package:json_annotation/json_annotation.dart'; import 'package:paperless_api/paperless_api.dart'; import 'package:paperless_mobile/core/database/tables/local_user_app_state.dart'; -import 'package:paperless_mobile/core/extensions/document_iterable_extensions.dart'; +import 'package:paperless_mobile/core/extensions/document_extensions.dart'; import 'package:paperless_mobile/core/notifier/document_changed_notifier.dart'; import 'package:paperless_mobile/core/repository/label_repository.dart'; import 'package:paperless_mobile/core/service/connectivity_status_service.dart'; diff --git a/lib/features/documents/view/pages/documents_page.dart b/lib/features/documents/view/pages/documents_page.dart index ce3f8e9..7e7fd99 100644 --- a/lib/features/documents/view/pages/documents_page.dart +++ b/lib/features/documents/view/pages/documents_page.dart @@ -6,6 +6,7 @@ import 'package:flutter_bloc/flutter_bloc.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/extensions/document_extensions.dart'; import 'package:paperless_mobile/core/extensions/flutter_extensions.dart'; import 'package:paperless_mobile/features/app_drawer/view/app_drawer.dart'; import 'package:paperless_mobile/features/document_search/view/sliver_search_bar.dart'; @@ -404,7 +405,11 @@ class _DocumentsPageState extends State { return SliverAdaptiveDocumentsView( viewType: state.viewType, onTap: (document) { - DocumentDetailsRoute($extra: document).push(context); + DocumentDetailsRoute( + title: document.title, + id: document.id, + thumbnailUrl: document.buildThumbnailUrl(context), + ).push(context); }, onSelected: context.read().toggleDocumentSelection, diff --git a/lib/features/documents/view/widgets/document_preview.dart b/lib/features/documents/view/widgets/document_preview.dart index 6bc7e6e..c3633b5 100644 --- a/lib/features/documents/view/widgets/document_preview.dart +++ b/lib/features/documents/view/widgets/document_preview.dart @@ -9,7 +9,8 @@ import 'package:provider/provider.dart'; import 'package:shimmer/shimmer.dart'; class DocumentPreview extends StatelessWidget { - final DocumentModel document; + final int documentId; + final String? title; final BoxFit fit; final Alignment alignment; final double borderRadius; @@ -19,13 +20,14 @@ class DocumentPreview extends StatelessWidget { const DocumentPreview({ super.key, - required this.document, + required this.documentId, this.fit = BoxFit.cover, this.alignment = Alignment.topCenter, this.borderRadius = 12.0, this.enableHero = true, this.scale = 1.1, this.isClickable = true, + this.title, }); @override @@ -34,12 +36,12 @@ class DocumentPreview extends StatelessWidget { child: GestureDetector( behavior: HitTestBehavior.translucent, onTap: isClickable - ? () => DocumentPreviewRoute($extra: document).push(context) + ? () => DocumentPreviewRoute(id: documentId).push(context) : null, child: Builder(builder: (context) { if (enableHero) { return Hero( - tag: "thumb_${document.id}", + tag: "thumb_$documentId", child: _buildPreview(context), ); } @@ -57,10 +59,9 @@ class DocumentPreview extends StatelessWidget { child: CachedNetworkImage( fit: fit, alignment: alignment, - cacheKey: "thumb_${document.id}", - imageUrl: context - .read() - .getThumbnailUrl(document.id), + cacheKey: "thumb_$documentId", + imageUrl: + context.read().getThumbnailUrl(documentId), errorWidget: (ctxt, msg, __) => Text(msg), placeholder: (context, value) => Shimmer.fromColors( baseColor: Colors.grey[300]!, diff --git a/lib/features/documents/view/widgets/items/document_detailed_item.dart b/lib/features/documents/view/widgets/items/document_detailed_item.dart index b0388bd..127457d 100644 --- a/lib/features/documents/view/widgets/items/document_detailed_item.dart +++ b/lib/features/documents/view/widgets/items/document_detailed_item.dart @@ -1,5 +1,6 @@ import 'dart:math'; +import 'package:flutter/gestures.dart'; import 'package:flutter/material.dart'; import 'package:flutter_html/flutter_html.dart'; import 'package:hive_flutter/adapters.dart'; @@ -56,6 +57,7 @@ class DocumentDetailedItem extends DocumentItem { final maxHeight = highlights != null ? min(600.0, availableHeight) : min(500.0, availableHeight); + final labels = context.watch().state; return Card( color: isSelected ? Theme.of(context).colorScheme.inversePrimary : null, child: InkWell( @@ -79,39 +81,71 @@ class DocumentDetailedItem extends DocumentItem { width: double.infinity, height: maxHeight / 2, ), - child: DocumentPreview( - document: document, - fit: BoxFit.cover, - alignment: Alignment.topCenter, + child: Stack( + fit: StackFit.expand, + children: [ + DocumentPreview( + documentId: document.id, + title: document.title, + ), + if (paperlessUser.canViewTags) + Align( + alignment: Alignment.bottomLeft, + child: TagsWidget( + tags: + document.tags.map((e) => labels.tags[e]!).toList(), + onTagSelected: onTagSelected, + ).padded(), + ), + ], ), ), Row( mainAxisAlignment: MainAxisAlignment.spaceBetween, + crossAxisAlignment: CrossAxisAlignment.start, children: [ - Text( - DateFormat.yMMMMd(Localizations.localeOf(context).toString()) - .format(document.created), - style: Theme.of(context) - .textTheme - .bodySmall - ?.apply(color: Theme.of(context).hintColor), + Expanded( + child: RichText( + maxLines: 1, + overflow: TextOverflow.ellipsis, + text: TextSpan( + style: Theme.of(context) + .textTheme + .bodySmall + ?.apply(color: Theme.of(context).hintColor), + text: DateFormat.yMMMMd( + Localizations.localeOf(context).toString()) + .format(document.created), + children: [ + if (paperlessUser.canViewDocumentTypes && + document.documentType != null) ...[ + const TextSpan(text: '\u30FB'), + TextSpan( + text: labels + .documentTypes[document.documentType]?.name, + recognizer: onDocumentTypeSelected != null + ? (TapGestureRecognizer() + ..onTap = () => onDocumentTypeSelected!( + document.documentType)) + : null, + ), + ], + ], + ), + ), ), if (document.archiveSerialNumber != null) - Row( - children: [ - Text( - '#${document.archiveSerialNumber}', - style: Theme.of(context) - .textTheme - .bodySmall - ?.apply(color: Theme.of(context).hintColor), - ), - ], + Text( + '#${document.archiveSerialNumber}', + style: Theme.of(context) + .textTheme + .bodySmall + ?.apply(color: Theme.of(context).hintColor), ), ], ).paddedLTRB(8, 8, 8, 4), Text( - document.title.isEmpty ? '-' : document.title, + document.title.isEmpty ? '(-)' : document.title, style: Theme.of(context).textTheme.titleMedium, maxLines: 2, overflow: TextOverflow.ellipsis, @@ -128,39 +162,11 @@ class DocumentDetailedItem extends DocumentItem { textStyle: Theme.of(context).textTheme.titleSmall?.apply( color: Theme.of(context).colorScheme.onSurfaceVariant, ), - correspondent: context - .watch() - .state - .correspondents[document.correspondent], + correspondent: + labels.correspondents[document.correspondent], ), ], - ).paddedLTRB(8, 0, 8, 4), - if (paperlessUser.canViewDocumentTypes) - Row( - children: [ - const Icon( - Icons.description_outlined, - size: 16, - ).paddedOnly(right: 4.0), - DocumentTypeWidget( - onSelected: onDocumentTypeSelected, - textStyle: Theme.of(context).textTheme.titleSmall?.apply( - color: Theme.of(context).colorScheme.onSurfaceVariant, - ), - documentType: context - .watch() - .state - .documentTypes[document.documentType], - ), - ], - ).paddedLTRB(8, 0, 8, 4), - if (paperlessUser.canViewTags) - TagsWidget( - tags: document.tags - .map((e) => context.watch().state.tags[e]!) - .toList(), - onTagSelected: onTagSelected, - ).padded(), + ).paddedLTRB(8, 0, 8, 8), if (highlights != null) Html( data: '

${highlights!}

', diff --git a/lib/features/documents/view/widgets/items/document_grid_item.dart b/lib/features/documents/view/widgets/items/document_grid_item.dart index 1293265..b727f3a 100644 --- a/lib/features/documents/view/widgets/items/document_grid_item.dart +++ b/lib/features/documents/view/widgets/items/document_grid_item.dart @@ -49,7 +49,7 @@ class DocumentGridItem extends DocumentItem { children: [ Positioned.fill( child: DocumentPreview( - document: document, + documentId: document.id, borderRadius: 12.0, enableHero: enableHeroAnimation, ), diff --git a/lib/features/documents/view/widgets/items/document_list_item.dart b/lib/features/documents/view/widgets/items/document_list_item.dart index 5a5e1d5..2767288 100644 --- a/lib/features/documents/view/widgets/items/document_list_item.dart +++ b/lib/features/documents/view/widgets/items/document_list_item.dart @@ -75,31 +75,34 @@ class DocumentListItem extends DocumentItem { ), ], ), - subtitle: Padding( - padding: const EdgeInsets.symmetric(vertical: 4), - child: RichText( - maxLines: 1, - overflow: TextOverflow.ellipsis, - text: TextSpan( - text: DateFormat.yMMMMd(Localizations.localeOf(context).toString()) - .format(document.created), - style: Theme.of(context) - .textTheme - .labelSmall - ?.apply(color: Colors.grey), - children: document.documentType != null - ? [ - const TextSpan(text: '\u30FB'), - TextSpan( - text: labels.documentTypes[document.documentType]?.name, - recognizer: onDocumentTypeSelected != null - ? (TapGestureRecognizer() - ..onTap = () => - onDocumentTypeSelected!(document.documentType)) - : null, - ), - ] - : null, + subtitle: IntrinsicWidth( + child: Padding( + padding: const EdgeInsets.symmetric(vertical: 4), + child: RichText( + maxLines: 1, + overflow: TextOverflow.ellipsis, + text: TextSpan( + text: + DateFormat.yMMMMd(Localizations.localeOf(context).toString()) + .format(document.created), + style: Theme.of(context) + .textTheme + .labelSmall + ?.apply(color: Colors.grey), + children: document.documentType != null + ? [ + const TextSpan(text: '\u30FB'), + TextSpan( + text: labels.documentTypes[document.documentType]?.name, + recognizer: onDocumentTypeSelected != null + ? (TapGestureRecognizer() + ..onTap = () => onDocumentTypeSelected!( + document.documentType)) + : null, + ), + ] + : null, + ), ), ), ), @@ -108,7 +111,7 @@ class DocumentListItem extends DocumentItem { aspectRatio: _a4AspectRatio, child: GestureDetector( child: DocumentPreview( - document: document, + documentId: document.id, fit: BoxFit.cover, alignment: Alignment.topCenter, enableHero: enableHeroAnimation, diff --git a/lib/features/inbox/view/widgets/inbox_item.dart b/lib/features/inbox/view/widgets/inbox_item.dart index aa2ee52..3b4c569 100644 --- a/lib/features/inbox/view/widgets/inbox_item.dart +++ b/lib/features/inbox/view/widgets/inbox_item.dart @@ -3,6 +3,7 @@ import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:paperless_api/paperless_api.dart'; import 'package:paperless_mobile/core/database/tables/local_user_account.dart'; +import 'package:paperless_mobile/core/extensions/document_extensions.dart'; import 'package:paperless_mobile/core/widgets/shimmer_placeholder.dart'; import 'package:paperless_mobile/core/workarounds/colored_chip.dart'; import 'package:paperless_mobile/core/extensions/flutter_extensions.dart'; @@ -153,7 +154,9 @@ class _InboxItemState extends State { behavior: HitTestBehavior.translucent, onTap: () { DocumentDetailsRoute( - $extra: widget.document, + title: widget.document.title, + id: widget.document.id, + thumbnailUrl: widget.document.buildThumbnailUrl(context), isLabelClickable: false, ).push(context); }, @@ -168,7 +171,8 @@ class _InboxItemState extends State { AspectRatio( aspectRatio: InboxItem.a4AspectRatio, child: DocumentPreview( - document: widget.document, + documentId: widget.document.id, + title: widget.document.title, fit: BoxFit.cover, alignment: Alignment.topCenter, enableHero: false, diff --git a/lib/features/linked_documents/view/linked_documents_page.dart b/lib/features/linked_documents/view/linked_documents_page.dart index b7c4428..4068b13 100644 --- a/lib/features/linked_documents/view/linked_documents_page.dart +++ b/lib/features/linked_documents/view/linked_documents_page.dart @@ -1,6 +1,7 @@ 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/extensions/document_extensions.dart'; import 'package:paperless_mobile/features/documents/view/widgets/adaptive_documents_view.dart'; import 'package:paperless_mobile/features/documents/view/widgets/selection/view_type_selection_widget.dart'; import 'package:paperless_mobile/features/linked_documents/cubit/linked_documents_cubit.dart'; @@ -53,8 +54,10 @@ class _LinkedDocumentsPageState extends State hasLoaded: state.hasLoaded, onTap: (document) { DocumentDetailsRoute( - $extra: document, + title: document.title, + id: document.id, isLabelClickable: false, + thumbnailUrl: document.buildThumbnailUrl(context), ).push(context); }, ), diff --git a/lib/features/saved_view_details/cubit/saved_view_preview_cubit.dart b/lib/features/saved_view_details/cubit/saved_view_preview_cubit.dart index e762e45..29b0bbe 100644 --- a/lib/features/saved_view_details/cubit/saved_view_preview_cubit.dart +++ b/lib/features/saved_view_details/cubit/saved_view_preview_cubit.dart @@ -1,7 +1,7 @@ import 'package:bloc/bloc.dart'; import 'package:collection/collection.dart'; import 'package:paperless_api/paperless_api.dart'; -import 'package:paperless_mobile/core/extensions/document_iterable_extensions.dart'; +import 'package:paperless_mobile/core/extensions/document_extensions.dart'; import 'package:paperless_mobile/core/notifier/document_changed_notifier.dart'; import 'package:paperless_mobile/core/service/connectivity_status_service.dart'; diff --git a/lib/features/saved_view_details/view/saved_view_preview.dart b/lib/features/saved_view_details/view/saved_view_preview.dart index 49f5ff9..324a15b 100644 --- a/lib/features/saved_view_details/view/saved_view_preview.dart +++ b/lib/features/saved_view_details/view/saved_view_preview.dart @@ -1,6 +1,7 @@ import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:paperless_api/paperless_api.dart'; +import 'package:paperless_mobile/core/extensions/document_extensions.dart'; import 'package:paperless_mobile/core/extensions/flutter_extensions.dart'; import 'package:paperless_mobile/features/documents/cubit/documents_cubit.dart'; import 'package:paperless_mobile/features/documents/view/widgets/items/document_list_item.dart'; @@ -55,8 +56,12 @@ class SavedViewPreview extends StatelessWidget { isSelected: false, isSelectionActive: false, onTap: (document) { - DocumentDetailsRoute($extra: document) - .push(context); + DocumentDetailsRoute( + title: document.title, + id: document.id, + thumbnailUrl: + document.buildThumbnailUrl(context), + ).push(context); }, onSelected: null, ), diff --git a/lib/features/similar_documents/view/similar_documents_view.dart b/lib/features/similar_documents/view/similar_documents_view.dart index ffaa844..4f9c14b 100644 --- a/lib/features/similar_documents/view/similar_documents_view.dart +++ b/lib/features/similar_documents/view/similar_documents_view.dart @@ -2,6 +2,7 @@ import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:paperless_api/paperless_api.dart'; import 'package:paperless_mobile/core/bloc/connectivity_cubit.dart'; +import 'package:paperless_mobile/core/extensions/document_extensions.dart'; import 'package:paperless_mobile/core/widgets/offline_widget.dart'; import 'package:paperless_mobile/features/documents/view/widgets/adaptive_documents_view.dart'; import 'package:paperless_mobile/features/paged_document_view/view/document_paging_view_mixin.dart'; @@ -66,7 +67,9 @@ class _SimilarDocumentsViewState extends State enableHeroAnimation: false, onTap: (document) { DocumentDetailsRoute( - $extra: document, + title: document.title, + id: document.id, + thumbnailUrl: document.buildThumbnailUrl(context), isLabelClickable: false, ).push(context); }, diff --git a/lib/routes/typed/branches/documents_route.dart b/lib/routes/typed/branches/documents_route.dart index 8fc0640..755bc25 100644 --- a/lib/routes/typed/branches/documents_route.dart +++ b/lib/routes/typed/branches/documents_route.dart @@ -14,7 +14,6 @@ import 'package:paperless_mobile/features/documents/view/pages/document_view.dar import 'package:paperless_mobile/features/documents/view/pages/documents_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/theme.dart'; class DocumentsBranch extends StatefulShellBranchData { @@ -33,14 +32,18 @@ class DocumentDetailsRoute extends GoRouteData { static final GlobalKey $parentNavigatorKey = outerShellNavigatorKey; + final int id; final bool isLabelClickable; - final DocumentModel $extra; final String? queryString; - + final String? thumbnailUrl; + final String? title; + const DocumentDetailsRoute({ - required this.$extra, + required this.id, this.isLabelClickable = true, this.queryString, + this.thumbnailUrl, + this.title, }); @override @@ -51,14 +54,15 @@ class DocumentDetailsRoute extends GoRouteData { context.read(), context.read(), context.read(), - initialDocument: $extra, - ) - ..loadFullContent() - ..loadMetaData(), + id: id, + )..initialize(), lazy: false, child: DocumentDetailsPage( + id: id, isLabelClickable: isLabelClickable, titleAndContentQueryString: queryString, + thumbnailUrl: thumbnailUrl, + title: title, ), ); } @@ -96,20 +100,19 @@ class EditDocumentRoute extends GoRouteData { class DocumentPreviewRoute extends GoRouteData { static final GlobalKey $parentNavigatorKey = outerShellNavigatorKey; - - final DocumentModel $extra; + final int id; final String? title; const DocumentPreviewRoute({ - required this.$extra, + required this.id, this.title, }); @override Widget build(BuildContext context, GoRouterState state) { return DocumentView( - documentBytes: context.read().download($extra), - title: title ?? $extra.title, + documentBytes: context.read().downloadDocument(id), + title: title, ); } } diff --git a/lib/routes/typed/shells/authenticated_route.dart b/lib/routes/typed/shells/authenticated_route.dart index 5ad17f4..3762cbe 100644 --- a/lib/routes/typed/shells/authenticated_route.dart +++ b/lib/routes/typed/shells/authenticated_route.dart @@ -68,7 +68,7 @@ part 'authenticated_route.g.dart'; path: "/documents", routes: [ TypedGoRoute( - path: "details", + path: "details/:id", name: R.documentDetails, ), TypedGoRoute( diff --git a/packages/paperless_api/lib/src/modules/documents_api/paperless_documents_api.dart b/packages/paperless_api/lib/src/modules/documents_api/paperless_documents_api.dart index ccbad6d..106fef1 100644 --- a/packages/paperless_api/lib/src/modules/documents_api/paperless_documents_api.dart +++ b/packages/paperless_api/lib/src/modules/documents_api/paperless_documents_api.dart @@ -21,11 +21,11 @@ abstract class PaperlessDocumentsApi { Future> findAll(DocumentFilter filter); Future find(int id); Future delete(DocumentModel doc); - Future getMetaData(DocumentModel document); + Future getMetaData(int id); Future> bulkAction(BulkAction action); Future getPreview(int docId); String getThumbnailUrl(int docId); - Future download(DocumentModel document, {bool original}); + Future downloadDocument(int id, {bool original}); Future downloadToFile( DocumentModel document, String localFilePath, { diff --git a/packages/paperless_api/lib/src/modules/documents_api/paperless_documents_api_impl.dart b/packages/paperless_api/lib/src/modules/documents_api/paperless_documents_api_impl.dart index cbc62b2..4df3d92 100644 --- a/packages/paperless_api/lib/src/modules/documents_api/paperless_documents_api_impl.dart +++ b/packages/paperless_api/lib/src/modules/documents_api/paperless_documents_api_impl.dart @@ -200,13 +200,13 @@ class PaperlessDocumentsApiImpl implements PaperlessDocumentsApi { } @override - Future download( - DocumentModel document, { + Future downloadDocument( + int id, { bool original = false, }) async { try { final response = await client.get( - "/api/documents/${document.id}/download/", + "/api/documents/$id/download/", queryParameters: {'original': original}, options: Options(responseType: ResponseType.bytes), ); @@ -242,14 +242,20 @@ class PaperlessDocumentsApiImpl implements PaperlessDocumentsApi { } @override - Future getMetaData(DocumentModel document) async { + Future getMetaData(int id) async { + debugPrint("Fetching data for /api/documents/$id/metadata/..."); + try { - final response = - await client.get("/api/documents/${document.id}/metadata/"); - return compute( - DocumentMetaData.fromJson, - response.data as Map, + final response = await client.get( + "/api/documents/$id/metadata/", + options: Options( + sendTimeout: Duration(seconds: 10), + receiveTimeout: Duration(seconds: 10), + ), ); + debugPrint("Fetched data for /api/documents/$id/metadata/."); + + return DocumentMetaData.fromJson(response.data); } on DioException catch (exception) { throw exception.unravel( orElse: const PaperlessApiException.unknown(), @@ -296,11 +302,17 @@ class PaperlessDocumentsApiImpl implements PaperlessDocumentsApi { @override Future find(int id) async { + debugPrint("Fetching data from /api/documents/$id/..."); try { final response = await client.get( "/api/documents/$id/", - options: Options(validateStatus: (status) => status == 200), + options: Options( + validateStatus: (status) => status == 200, + sendTimeout: Duration(seconds: 10), + receiveTimeout: Duration(seconds: 10), + ), ); + debugPrint("Fetched data for /api/documents/$id/."); return DocumentModel.fromJson(response.data); } on DioException catch (exception) { throw exception.unravel(