diff --git a/android/app/build.gradle b/android/app/build.gradle index 20e446a..b41fb77 100644 --- a/android/app/build.gradle +++ b/android/app/build.gradle @@ -72,6 +72,10 @@ android { release { signingConfig signingConfigs.release } + + debug { + applicationIdSuffix ".debug" + } } } diff --git a/android/fastlane/metadata/android/de-DE/changelogs/54.txt b/android/fastlane/metadata/android/de-DE/changelogs/54.txt new file mode 100644 index 0000000..5fcca68 --- /dev/null +++ b/android/fastlane/metadata/android/de-DE/changelogs/54.txt @@ -0,0 +1,4 @@ +* Neu: App-Logs werden in Dateien geschrieben und können auch direkt in der App eingesehen werden +* Optimierung der Datums-Eingabe durch neues Eingabemaske +* Schneller Wechsel zwischen Dokumenten-PDF Ansicht und Bearbeitungsmaske +* Kleinere Visuelle Anpassungen und Bugfixes \ No newline at end of file diff --git a/android/fastlane/metadata/android/en-US/changelogs/54.txt b/android/fastlane/metadata/android/en-US/changelogs/54.txt new file mode 100644 index 0000000..3e8e25e --- /dev/null +++ b/android/fastlane/metadata/android/en-US/changelogs/54.txt @@ -0,0 +1,4 @@ +* New: App-Logs are written to local files and can also be viewed in-app +* New and optimized date input fields +* Quickly switch between editing your document and a PDF-View +* Minor visual changes and bug fixes \ No newline at end of file diff --git a/assets/fonts/RobotoMono-Regular.ttf b/assets/fonts/RobotoMono-Regular.ttf new file mode 100644 index 0000000..6df2b25 Binary files /dev/null and b/assets/fonts/RobotoMono-Regular.ttf differ diff --git a/lib/core/config/hive/custom_adapters/theme_mode_adapter.dart b/lib/core/database/hive/custom_adapters/theme_mode_adapter.dart similarity index 93% rename from lib/core/config/hive/custom_adapters/theme_mode_adapter.dart rename to lib/core/database/hive/custom_adapters/theme_mode_adapter.dart index 204ad18..d708426 100644 --- a/lib/core/config/hive/custom_adapters/theme_mode_adapter.dart +++ b/lib/core/database/hive/custom_adapters/theme_mode_adapter.dart @@ -1,6 +1,6 @@ import 'package:flutter/material.dart'; import 'package:hive_flutter/adapters.dart'; -import 'package:paperless_mobile/core/config/hive/hive_config.dart'; +import 'package:paperless_mobile/core/database/hive/hive_config.dart'; class ThemeModeAdapter extends TypeAdapter { @override diff --git a/lib/core/config/hive/hive_config.dart b/lib/core/database/hive/hive_config.dart similarity index 96% rename from lib/core/config/hive/hive_config.dart rename to lib/core/database/hive/hive_config.dart index 64f5a31..38eb172 100644 --- a/lib/core/config/hive/hive_config.dart +++ b/lib/core/database/hive/hive_config.dart @@ -1,6 +1,6 @@ import 'package:hive_flutter/adapters.dart'; import 'package:paperless_api/paperless_api.dart'; -import 'package:paperless_mobile/core/config/hive/custom_adapters/theme_mode_adapter.dart'; +import 'package:paperless_mobile/core/database/hive/custom_adapters/theme_mode_adapter.dart'; import 'package:paperless_mobile/core/database/tables/global_settings.dart'; import 'package:paperless_mobile/core/database/tables/local_user_app_state.dart'; import 'package:paperless_mobile/core/database/tables/user_credentials.dart'; diff --git a/lib/core/config/hive/hive_extensions.dart b/lib/core/database/hive/hive_extensions.dart similarity index 96% rename from lib/core/config/hive/hive_extensions.dart rename to lib/core/database/hive/hive_extensions.dart index c519dcd..6288eac 100644 --- a/lib/core/config/hive/hive_extensions.dart +++ b/lib/core/database/hive/hive_extensions.dart @@ -4,7 +4,7 @@ import 'dart:typed_data'; import 'package:flutter_secure_storage/flutter_secure_storage.dart'; import 'package:hive_flutter/adapters.dart'; -import 'package:paperless_mobile/core/config/hive/hive_config.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/core/database/tables/local_user_app_state.dart'; diff --git a/lib/core/database/tables/global_settings.dart b/lib/core/database/tables/global_settings.dart index fdcccbc..fbaf73b 100644 --- a/lib/core/database/tables/global_settings.dart +++ b/lib/core/database/tables/global_settings.dart @@ -1,6 +1,6 @@ import 'package:flutter/material.dart'; import 'package:hive/hive.dart'; -import 'package:paperless_mobile/core/config/hive/hive_config.dart'; +import 'package:paperless_mobile/core/database/hive/hive_config.dart'; import 'package:paperless_mobile/features/settings/model/color_scheme_option.dart'; import 'package:paperless_mobile/features/settings/model/file_download_type.dart'; diff --git a/lib/core/database/tables/local_user_account.dart b/lib/core/database/tables/local_user_account.dart index d54d890..4b5e98d 100644 --- a/lib/core/database/tables/local_user_account.dart +++ b/lib/core/database/tables/local_user_account.dart @@ -1,6 +1,6 @@ import 'package:hive_flutter/adapters.dart'; import 'package:paperless_api/paperless_api.dart'; -import 'package:paperless_mobile/core/config/hive/hive_config.dart'; +import 'package:paperless_mobile/core/database/hive/hive_config.dart'; import 'package:paperless_mobile/core/database/tables/local_user_settings.dart'; part 'local_user_account.g.dart'; diff --git a/lib/core/database/tables/local_user_app_state.dart b/lib/core/database/tables/local_user_app_state.dart index 49812e2..89f5360 100644 --- a/lib/core/database/tables/local_user_app_state.dart +++ b/lib/core/database/tables/local_user_app_state.dart @@ -1,6 +1,6 @@ import 'package:hive_flutter/adapters.dart'; import 'package:paperless_api/paperless_api.dart'; -import 'package:paperless_mobile/core/config/hive/hive_config.dart'; +import 'package:paperless_mobile/core/database/hive/hive_config.dart'; import 'package:paperless_mobile/core/database/tables/global_settings.dart'; import 'package:paperless_mobile/features/settings/model/view_type.dart'; diff --git a/lib/core/database/tables/local_user_settings.dart b/lib/core/database/tables/local_user_settings.dart index 4398e85..f495072 100644 --- a/lib/core/database/tables/local_user_settings.dart +++ b/lib/core/database/tables/local_user_settings.dart @@ -1,5 +1,5 @@ import 'package:hive/hive.dart'; -import 'package:paperless_mobile/core/config/hive/hive_config.dart'; +import 'package:paperless_mobile/core/database/hive/hive_config.dart'; part 'local_user_settings.g.dart'; diff --git a/lib/core/database/tables/user_credentials.dart b/lib/core/database/tables/user_credentials.dart index bd8ac77..61c95cd 100644 --- a/lib/core/database/tables/user_credentials.dart +++ b/lib/core/database/tables/user_credentials.dart @@ -1,5 +1,5 @@ import 'package:hive/hive.dart'; -import 'package:paperless_mobile/core/config/hive/hive_config.dart'; +import 'package:paperless_mobile/core/database/hive/hive_config.dart'; import 'package:paperless_mobile/features/login/model/client_certificate.dart'; part 'user_credentials.g.dart'; diff --git a/lib/extensions/dart_extensions.dart b/lib/core/extensions/dart_extensions.dart similarity index 90% rename from lib/extensions/dart_extensions.dart rename to lib/core/extensions/dart_extensions.dart index 9b23841..9bca5ad 100644 --- a/lib/extensions/dart_extensions.dart +++ b/lib/core/extensions/dart_extensions.dart @@ -35,6 +35,10 @@ extension DateHelpers on DateTime { yesterday.month == month && yesterday.year == year; } + + bool isOnSameDayAs(DateTime other) { + return other.day == day && other.month == month && other.year == year; + } } extension StringNormalizer on String { diff --git a/lib/core/extensions/document_extensions.dart b/lib/core/extensions/document_extensions.dart new file mode 100644 index 0000000..a767023 --- /dev/null +++ b/lib/core/extensions/document_extensions.dart @@ -0,0 +1,25 @@ +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 { + Iterable get ids => map((e) => e.id); + + Iterable withDocumentreplaced(DocumentModel document) { + return map((e) => e.id == document.id ? document : e); + } + + bool containsDocument(DocumentModel document) { + return ids.contains(document.id); + } + + Iterable withDocumentRemoved(DocumentModel document) { + return whereNot((element) => element.id == document.id); + } +} + +extension SessionAwareDownloadIdExtension on DocumentModel { + String buildThumbnailUrl(BuildContext context) => + context.read().getThumbnailUrl(id); +} diff --git a/lib/extensions/flutter_extensions.dart b/lib/core/extensions/flutter_extensions.dart similarity index 100% rename from lib/extensions/flutter_extensions.dart rename to lib/core/extensions/flutter_extensions.dart diff --git a/lib/core/interceptor/dio_http_error_interceptor.dart b/lib/core/interceptor/dio_http_error_interceptor.dart index 6c8fb1b..fd5ea63 100644 --- a/lib/core/interceptor/dio_http_error_interceptor.dart +++ b/lib/core/interceptor/dio_http_error_interceptor.dart @@ -38,6 +38,8 @@ class DioHttpErrorInterceptor extends Interceptor { const PaperlessApiException(ErrorCode.missingClientCertificate), ), ); + } else { + handler.reject(err); } } } diff --git a/lib/core/notifier/document_changed_notifier.dart b/lib/core/notifier/document_changed_notifier.dart index e1c2bba..22aa023 100644 --- a/lib/core/notifier/document_changed_notifier.dart +++ b/lib/core/notifier/document_changed_notifier.dart @@ -1,6 +1,5 @@ import 'dart:async'; -import 'package:flutter/foundation.dart'; import 'package:paperless_api/paperless_api.dart'; import 'package:rxdart/subjects.dart'; @@ -17,12 +16,10 @@ class DocumentChangedNotifier { Stream get $deleted => _deleted.asBroadcastStream(); void notifyUpdated(DocumentModel updated) { - debugPrint("Notifying updated document ${updated.id}"); _updated.add(updated); } void notifyDeleted(DocumentModel deleted) { - debugPrint("Notifying deleted document ${deleted.id}"); _deleted.add(deleted); } @@ -30,14 +27,15 @@ class DocumentChangedNotifier { Object subscriber, { DocumentChangedCallback? onUpdated, DocumentChangedCallback? onDeleted, + Iterable? ids, }) { _subscribers.putIfAbsent( subscriber, () => [ - _updated.listen((value) { + _updated.where((doc) => ids?.contains(doc.id) ?? true).listen((value) { onUpdated?.call(value); }), - _deleted.listen((value) { + _deleted.where((doc) => ids?.contains(doc.id) ?? true).listen((value) { onDeleted?.call(value); }), ], diff --git a/lib/core/repository/label_repository.dart b/lib/core/repository/label_repository.dart index e7b73dc..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,19 +10,12 @@ class LabelRepository extends PersistentRepository { LabelRepository(this._api) : super(const LabelRepositoryState()); Future initialize() async { - debugPrint("[LabelRepository] initialize() called."); - try { - await Future.wait([ - findAllCorrespondents(), - findAllDocumentTypes(), - findAllStoragePaths(), - findAllTags(), - ]); - } catch (error, stackTrace) { - debugPrint( - "[LabelRepository] An error occurred in initialize(): ${error.toString()}"); - debugPrintStack(stackTrace: stackTrace); - } + await Future.wait([ + findAllCorrespondents(), + findAllDocumentTypes(), + findAllStoragePaths(), + findAllTags(), + ]); } Future createTag(Tag object) async { @@ -95,9 +87,7 @@ class LabelRepository extends PersistentRepository { Future> findAllCorrespondents( [Iterable? ids]) async { - debugPrint("Loading correspondents..."); final correspondents = await _api.getCorrespondents(ids); - debugPrint("${correspondents.length} correspondents successfully loaded."); final updatedState = { ...state.correspondents, }..addAll({for (var element in correspondents) element.id!: element}); diff --git a/lib/core/security/session_manager.dart b/lib/core/security/session_manager.dart index 8f2aba3..2244d34 100644 --- a/lib/core/security/session_manager.dart +++ b/lib/core/security/session_manager.dart @@ -39,14 +39,6 @@ class SessionManager extends ValueNotifier { DioUnauthorizedInterceptor(), DioHttpErrorInterceptor(), DioOfflineInterceptor(), - PrettyDioLogger( - compact: true, - responseBody: false, - responseHeader: false, - request: false, - requestBody: false, - requestHeader: false, - ), RetryOnConnectionChangeInterceptor(dio: dio) ]); return dio; diff --git a/lib/core/service/file_service.dart b/lib/core/service/file_service.dart index 32482a2..2dfa6a3 100644 --- a/lib/core/service/file_service.dart +++ b/lib/core/service/file_service.dart @@ -1,125 +1,260 @@ import 'dart:io'; import 'package:flutter/foundation.dart'; +import 'package:paperless_mobile/features/logging/data/logger.dart'; +import 'package:paperless_mobile/features/logging/utils/redaction_utils.dart'; +import 'package:paperless_mobile/helpers/format_helpers.dart'; +import 'package:path/path.dart' as p; import 'package:path_provider/path_provider.dart'; import 'package:rxdart/rxdart.dart'; import 'package:uuid/uuid.dart'; class FileService { - const FileService._(); + FileService._(); - static Future saveToFile( + static FileService? _singleton; + + late final Directory _logDirectory; + late final Directory _temporaryDirectory; + late final Directory _documentsDirectory; + late final Directory _downloadsDirectory; + late final Directory _uploadDirectory; + late final Directory _temporaryScansDirectory; + + Directory get logDirectory => _logDirectory; + Directory get temporaryDirectory => _temporaryDirectory; + Directory get documentsDirectory => _documentsDirectory; + Directory get downloadsDirectory => _downloadsDirectory; + Directory get uploadDirectory => _uploadDirectory; + Directory get temporaryScansDirectory => _temporaryScansDirectory; + + Future initialize() async { + try { + await _initTemporaryDirectory(); + await _initTemporaryScansDirectory(); + await _initUploadDirectory(); + await _initLogDirectory(); + await _initDownloadsDirectory(); + await _initializeDocumentsDirectory(); + } catch (error, stackTrace) { + debugPrint("Could not initialize directories."); + debugPrint(error.toString()); + debugPrintStack(stackTrace: stackTrace); + } + } + + /// Make sure to call and await initialize before accessing any of the instance members. + static FileService get instance { + _singleton ??= FileService._(); + return _singleton!; + } + + Future saveToFile( Uint8List bytes, String filename, ) async { - final dir = await documentsDirectory; - File file = File("${dir.path}/$filename"); + File file = File(p.join(_logDirectory.path, filename)); + logger.fd( + "Writing bytes to file $filename", + methodName: 'saveToFile', + className: runtimeType.toString(), + ); return file..writeAsBytes(bytes); } - static Future getDirectory(PaperlessDirectoryType type) { + Directory getDirectory(PaperlessDirectoryType type) { return switch (type) { - PaperlessDirectoryType.documents => documentsDirectory, - PaperlessDirectoryType.temporary => temporaryDirectory, - PaperlessDirectoryType.scans => temporaryScansDirectory, - PaperlessDirectoryType.download => downloadsDirectory, - PaperlessDirectoryType.upload => uploadDirectory, + PaperlessDirectoryType.documents => _documentsDirectory, + PaperlessDirectoryType.temporary => _temporaryDirectory, + PaperlessDirectoryType.scans => _temporaryScansDirectory, + PaperlessDirectoryType.download => _downloadsDirectory, + PaperlessDirectoryType.upload => _uploadDirectory, + PaperlessDirectoryType.logs => _logDirectory, }; } - static Future allocateTemporaryFile( + /// + /// Returns a [File] pointing to a temporary file in the directory specified by [type]. + /// If [create] is true, the file will be created. + /// If [fileName] is left blank, a random UUID will be generated. + /// + Future allocateTemporaryFile( PaperlessDirectoryType type, { required String extension, String? fileName, + bool create = false, }) async { - final dir = await getDirectory(type); - final _fileName = (fileName ?? const Uuid().v1()) + '.$extension'; - return File('${dir?.path}/$_fileName'); - } - - static Future get temporaryDirectory => getTemporaryDirectory(); - - static Future get documentsDirectory async { - if (Platform.isAndroid) { - return (await getExternalStorageDirectories( - type: StorageDirectory.documents, - ))! - .first; - } else if (Platform.isIOS) { - final dir = await getApplicationDocumentsDirectory() - .then((dir) => Directory('${dir.path}/documents')); - return dir.create(recursive: true); - } else { - throw UnsupportedError("Platform not supported."); + final dir = getDirectory(type); + final filename = (fileName ?? const Uuid().v1()) + '.$extension'; + final file = File(p.join(dir.path, filename)); + if (create) { + await file.create(recursive: true); } + return file; } - static Future get downloadsDirectory async { - if (Platform.isAndroid) { - Directory directory = Directory('/storage/emulated/0/Download'); - if (!directory.existsSync()) { - final downloadsDir = await getExternalStorageDirectories( - type: StorageDirectory.downloads, - ); - directory = downloadsDir!.first; - } - return directory; - } else if (Platform.isIOS) { - final appDir = await getApplicationDocumentsDirectory(); - final dir = Directory('${appDir.path}/downloads'); - return dir.create(recursive: true); - } else { - throw UnsupportedError("Platform not supported."); - } + Future getConsumptionDirectory({required String userId}) async { + return Directory(p.join(_uploadDirectory.path, userId)) + .create(recursive: true); } - static Future get uploadDirectory async { - final dir = await getApplicationDocumentsDirectory() - .then((dir) => Directory('${dir.path}/upload')); - return dir.create(recursive: true); - } + Future clearUserData({required String userId}) async { + final redactedId = redactUserId(userId); + logger.fd( + "Clearing data for user $redactedId...", + className: runtimeType.toString(), + methodName: "clearUserData", + ); - static Future getConsumptionDirectory( - {required String userId}) async { - final uploadDir = - await uploadDirectory.then((dir) => Directory('${dir.path}/$userId')); - return uploadDir.create(recursive: true); - } - - static Future get temporaryScansDirectory async { - final tempDir = await temporaryDirectory; - final scansDir = Directory('${tempDir.path}/scans'); - return scansDir.create(recursive: true); - } - - static Future clearUserData({required String userId}) async { - final scanDir = await temporaryScansDirectory; - final tempDir = await temporaryDirectory; + final scanDirSize = + formatBytes(await getDirSizeInBytes(_temporaryScansDirectory)); + final tempDirSize = + formatBytes(await getDirSizeInBytes(_temporaryDirectory)); final consumptionDir = await getConsumptionDirectory(userId: userId); - await scanDir.delete(recursive: true); - await tempDir.delete(recursive: true); + final consumptionDirSize = + formatBytes(await getDirSizeInBytes(consumptionDir)); + + logger.ft( + "Removing scans...", + className: runtimeType.toString(), + methodName: "clearUserData", + ); + await _temporaryScansDirectory.delete(recursive: true); + logger.ft( + "Removed $scanDirSize...", + className: runtimeType.toString(), + methodName: "clearUserData", + ); + logger.ft( + "Removing temporary files and cache content...", + className: runtimeType.toString(), + methodName: "clearUserData", + ); + + await _temporaryDirectory.delete(recursive: true); + logger.ft( + "Removed $tempDirSize...", + className: runtimeType.toString(), + methodName: "clearUserData", + ); + + logger.ft( + "Removing files waiting for consumption...", + className: runtimeType.toString(), + methodName: "clearUserData", + ); await consumptionDir.delete(recursive: true); - } - - static Future clearDirectoryContent(PaperlessDirectoryType type) async { - final dir = await getDirectory(type); - - if (dir == null || !(await dir.exists())) { - return; - } - - await Future.wait( - dir.listSync().map((item) => item.delete(recursive: true)), + logger.ft( + "Removed $consumptionDirSize...", + className: runtimeType.toString(), + methodName: "clearUserData", ); } - static Future> getAllFiles(Directory directory) { + Future clearDirectoryContent( + PaperlessDirectoryType type, { + bool filesOnly = false, + }) async { + final dir = getDirectory(type); + final dirSize = await getDirSizeInBytes(dir); + if (!await dir.exists()) { + return 0; + } + final streamedEntities = filesOnly + ? dir.list().whereType().cast() + : dir.list(); + + final entities = await streamedEntities.toList(); + await Future.wait([ + for (var entity in entities) entity.delete(recursive: !filesOnly), + ]); + return dirSize; + } + + Future> getAllFiles(Directory directory) { return directory.list().whereType().toList(); } - static Future> getAllSubdirectories(Directory directory) { + Future> getAllSubdirectories(Directory directory) { return directory.list().whereType().toList(); } + + Future getDirSizeInBytes(Directory dir) async { + return dir + .list(recursive: true) + .fold(0, (previous, element) => previous + element.statSync().size); + } + + Future _initTemporaryDirectory() async { + _temporaryDirectory = await getTemporaryDirectory(); + } + + Future _initializeDocumentsDirectory() async { + if (Platform.isAndroid) { + final dirs = + await getExternalStorageDirectories(type: StorageDirectory.documents); + _documentsDirectory = dirs!.first; + return; + } else if (Platform.isIOS) { + final dir = await getApplicationDocumentsDirectory(); + _documentsDirectory = await Directory(p.join(dir.path, 'documents')) + .create(recursive: true); + return; + } else { + throw UnsupportedError("Platform not supported."); + } + } + + Future _initLogDirectory() async { + if (Platform.isAndroid) { + _logDirectory = + await getExternalStorageDirectories(type: StorageDirectory.documents) + .then((directory) async => + directory?.firstOrNull ?? + await getApplicationDocumentsDirectory()) + .then((directory) => + Directory('${directory.path}/logs').create(recursive: true)); + return; + } else if (Platform.isIOS) { + _logDirectory = await getApplicationDocumentsDirectory().then( + (value) => Directory('${value.path}/logs').create(recursive: true)); + return; + } + throw UnsupportedError("Platform not supported."); + } + + Future _initDownloadsDirectory() async { + if (Platform.isAndroid) { + var directory = Directory('/storage/emulated/0/Download'); + if (!await directory.exists()) { + final downloadsDir = await getExternalStorageDirectories( + type: StorageDirectory.downloads, + ); + directory = await downloadsDir!.first.create(recursive: true); + } + _downloadsDirectory = directory; + return; + } else if (Platform.isIOS) { + final appDir = await getApplicationDocumentsDirectory(); + final dir = Directory('${appDir.path}/downloads'); + _downloadsDirectory = await dir.create(recursive: true); + return; + } else { + throw UnsupportedError("Platform not supported."); + } + } + + Future _initUploadDirectory() async { + final dir = await getApplicationDocumentsDirectory() + .then((dir) => Directory('${dir.path}/upload')); + _uploadDirectory = await dir.create(recursive: true); + } + + Future _initTemporaryScansDirectory() async { + _temporaryScansDirectory = + await Directory(p.join(_temporaryDirectory.path, 'scans')) + .create(recursive: true); + } } enum PaperlessDirectoryType { @@ -127,5 +262,6 @@ enum PaperlessDirectoryType { temporary, scans, download, - upload; + upload, + logs; } diff --git a/lib/core/service/github_issue_service.dart b/lib/core/service/github_issue_service.dart index 7244704..853aab3 100644 --- a/lib/core/service/github_issue_service.dart +++ b/lib/core/service/github_issue_service.dart @@ -2,7 +2,7 @@ import 'package:flutter/material.dart'; import 'package:paperless_mobile/core/model/github_error_report.model.dart'; import 'package:paperless_mobile/core/widgets/error_report_page.dart'; import 'package:url_launcher/url_launcher.dart'; -import 'package:paperless_mobile/extensions/dart_extensions.dart'; +import 'package:paperless_mobile/core/extensions/dart_extensions.dart'; class GithubIssueService { static void openCreateGithubIssue({ diff --git a/lib/core/widgets/error_report_page.dart b/lib/core/widgets/error_report_page.dart index ab0326b..34d344b 100644 --- a/lib/core/widgets/error_report_page.dart +++ b/lib/core/widgets/error_report_page.dart @@ -3,7 +3,7 @@ import 'package:flutter/services.dart'; import 'package:flutter_form_builder/flutter_form_builder.dart'; import 'package:paperless_mobile/core/model/github_error_report.model.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/core/extensions/flutter_extensions.dart'; class ErrorReportPage extends StatefulWidget { final StackTrace? stackTrace; diff --git a/lib/core/widgets/form_builder_fields/extended_date_range_form_field/extended_date_range_dialog.dart b/lib/core/widgets/form_builder_fields/extended_date_range_form_field/extended_date_range_dialog.dart index aac24e4..f051cfa 100644 --- a/lib/core/widgets/form_builder_fields/extended_date_range_form_field/extended_date_range_dialog.dart +++ b/lib/core/widgets/form_builder_fields/extended_date_range_form_field/extended_date_range_dialog.dart @@ -3,7 +3,7 @@ import 'package:flutter_form_builder/flutter_form_builder.dart'; import 'package:intl/intl.dart'; import 'package:paperless_api/paperless_api.dart'; import 'package:paperless_mobile/core/widgets/form_builder_fields/extended_date_range_form_field/form_builder_relative_date_range_field.dart'; -import 'package:paperless_mobile/extensions/flutter_extensions.dart'; +import 'package:paperless_mobile/core/extensions/flutter_extensions.dart'; import 'package:paperless_mobile/generated/l10n/app_localizations.dart'; class ExtendedDateRangeDialog extends StatefulWidget { 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 new file mode 100644 index 0000000..eb1ab5f --- /dev/null +++ b/lib/core/widgets/form_builder_fields/form_builder_localized_date_picker.dart @@ -0,0 +1,439 @@ +// ignore_for_file: invalid_use_of_protected_member + +import 'dart:collection'; + +import 'package:collection/collection.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_form_builder/flutter_form_builder.dart'; +import 'package:intl/intl.dart'; +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; + final Locale locale; + final String labelText; + final Widget? prefixIcon; + final DateTime? initialValue; + final DateTime firstDate; + final DateTime lastDate; + final FocusNode? focusNode; + + /// If set to true, the field will not throw any validation errors when empty. + final bool allowUnset; + + const FormBuilderLocalizedDatePicker({ + super.key, + required this.name, + this.initialValue, + required this.firstDate, + required this.lastDate, + required this.locale, + required this.labelText, + this.prefixIcon, + this.allowUnset = false, + this.focusNode, + }); + + @override + State createState() => + _FormBuilderLocalizedDatePickerState(); +} + +class _FormBuilderLocalizedDatePickerState + extends State { + late final String _separator; + late final String _format; + + final _textFieldControls = + LinkedList<_NeighbourAwareDateInputSegmentControls>(); + String? _error; + bool _temporarilyDisableListeners = false; + @override + void initState() { + super.initState(); + final format = + DateFormat.yMd(widget.locale.toString()).format(DateTime(1000, 11, 22)); + _separator = format.replaceAll(RegExp(r'\d'), '').characters.first; + _format = format + .replaceAll("1000", "yyyy") + .replaceAll("11", "MM") + .replaceAll("22", "dd"); + + final components = _format.split(_separator); + for (int i = 0; i < components.length; i++) { + final formatString = components[i]; + final initialText = widget.initialValue != null + ? DateFormat(formatString).format(widget.initialValue!) + : null; + final defaultFocusNode = FocusNode(debugLabel: formatString); + final focusNode = + i == 0 ? (widget.focusNode ?? defaultFocusNode) : defaultFocusNode; + final controls = _NeighbourAwareDateInputSegmentControls( + node: focusNode, + controller: TextEditingController(text: initialText), + format: formatString, + position: i, + type: _DateInputSegment.fromPattern(formatString), + ); + _textFieldControls.add(controls); + controls.controller.addListener(() { + if (_temporarilyDisableListeners) { + return; + } + if (controls.controller.selection.isCollapsed && + controls.controller.text.length == controls.format.length) { + controls.next?.node.requestFocus(); + } + }); + controls.node.addListener(() { + if (_temporarilyDisableListeners || !controls.node.hasFocus) { + return; + } + controls.controller.selection = TextSelection( + baseOffset: 0, + extentOffset: controls.controller.text.length, + ); + }); + } + } + + @override + void dispose() { + for (var controls in _textFieldControls) { + controls.node.dispose(); + controls.controller.dispose(); + } + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return RawKeyboardListener( + focusNode: FocusNode(), + onKey: (value) { + if (value.logicalKey == LogicalKeyboardKey.backspace && + value is RawKeyDownEvent) { + final currentFocus = _textFieldControls + .where((element) => element.node.hasFocus) + .firstOrNull; + if (currentFocus == null) { + return; + } + if (currentFocus.controller.text.isEmpty) { + currentFocus.previous?.node.requestFocus(); + final endOffset = currentFocus.previous?.controller.text.length; + currentFocus.previous?.controller.selection = + TextSelection.collapsed(offset: endOffset ?? 0); + } + } + }, + child: FormBuilderField( + name: widget.name, + validator: _validateDate, + onChanged: (value) { + assert(!widget.allowUnset && value != null); + if (value == null) { + return; + } + // 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; + }); + }, + autovalidateMode: AutovalidateMode.onUserInteraction, + initialValue: widget.initialValue != null + ? FormDateTime.fromDateTime(widget.initialValue!) + : null, + builder: (field) { + return GestureDetector( + onTap: () { + _textFieldControls.first.node.requestFocus(); + }, + child: InputDecorator( + textAlignVertical: TextAlignVertical.bottom, + decoration: InputDecoration( + errorText: _error, + labelText: widget.labelText, + suffixIcon: Row( + mainAxisSize: MainAxisSize.min, + children: [ + IconButton( + icon: const Icon(Icons.calendar_month_outlined), + onPressed: () async { + final selectedDate = await showDatePicker( + context: context, + initialDate: + field.value?.toDateTime() ?? DateTime.now(), + firstDate: widget.firstDate, + lastDate: widget.lastDate, + initialEntryMode: DatePickerEntryMode.calendarOnly, + ); + if (selectedDate != null) { + final formDate = + FormDateTime.fromDateTime(selectedDate); + _temporarilyDisableListeners = true; + _updateInputsWithDate(formDate); + field.didChange(formDate); + _temporarilyDisableListeners = false; + } + }, + ), + 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), + ), + child: Row( + children: [ + for (var s in _textFieldControls) ...[ + IntrinsicWidth( + child: _buildDateSegmentInput(s, context, field), + ), + ], + ], + ), + ), + ); + }, + ), + ); + } + + String? _validateDate(FormDateTime? date) { + if (widget.allowUnset && date == null) { + return null; + } + if (date == null) { + return S.of(context)!.thisFieldIsRequired; + } + 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 (d.isAfter(widget.lastDate)) { + final formattedDateHint = + DateFormat.yMd(widget.locale.toString()).format(widget.lastDate); + return "Date must be before $formattedDateHint."; + } + return null; + } + + 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, + ) { + return TextFormField( + onFieldSubmitted: (value) { + if (value.length < controls.format.length) { + controls.controller.text = value.padLeft(controls.format.length, '0'); + } + controls.next?.node.requestFocus(); + }, + style: const TextStyle(fontFamily: 'RobotoMono'), + keyboardType: TextInputType.datetime, + textInputAction: + controls.position < 2 ? TextInputAction.next : TextInputAction.done, + controller: controls.controller, + focusNode: _textFieldControls.elementAt(controls.position).node, + maxLength: controls.format.length, + maxLengthEnforcement: MaxLengthEnforcement.enforced, + enableInteractiveSelection: false, + onChanged: (value) { + 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 => fieldValue.copyWith(day: number), + _DateInputSegment.month => fieldValue.copyWith(month: number), + _DateInputSegment.year => fieldValue.copyWith(year: number), + }; + field.setValue(newValue); + } + }, + inputFormatters: [ + FilteringTextInputFormatter.digitsOnly, + RangeLimitedInputFormatter( + 1, + switch (controls.type) { + _DateInputSegment.day => 31, + _DateInputSegment.month => 12, + _DateInputSegment.year => 9999, + }, + ), + ], + onEditingComplete: () { + if (field.value != null) { + _updateInputsWithDate(field.value!, disableListeners: true); + } + FocusScope.of(context).unfocus(); + }, + decoration: InputDecoration( + isDense: true, + suffixIcon: controls.position < 2 + ? Text( + _separator, + style: const TextStyle(fontFamily: 'RobotoMono'), + ).paddedSymmetrically(horizontal: 2) + : null, + suffixIconConstraints: const BoxConstraints.tightFor(), + fillColor: Colors.blue.values[controls.position], + counterText: '', + contentPadding: EdgeInsets.zero, + hintText: controls.format, + hintStyle: const TextStyle(fontFamily: "RobotoMono"), + border: Theme.of(context).inputDecorationTheme.border?.copyWith( + borderSide: const BorderSide( + width: 0, + style: BorderStyle.none, + ), + ), + ), + ); + } +} + +enum _DateInputSegment { + day, + month, + year; + + static _DateInputSegment fromPattern(String pattern) { + final char = pattern.characters.first; + return switch (char) { + 'd' => day, + 'M' => month, + 'y' => year, + _ => throw ArgumentError.value(pattern), + }; + } +} + +final class _NeighbourAwareDateInputSegmentControls + with LinkedListEntry<_NeighbourAwareDateInputSegmentControls> { + final FocusNode node; + final TextEditingController controller; + final int position; + final String format; + final _DateInputSegment type; + + _NeighbourAwareDateInputSegmentControls({ + required this.node, + required this.controller, + required this.format, + required this.position, + required this.type, + }); +} + +class RangeLimitedInputFormatter extends TextInputFormatter { + RangeLimitedInputFormatter( + this.minimum, + this.maximum, + ) : assert(minimum < maximum); + + final int minimum; + final int maximum; + + @override + TextEditingValue formatEditUpdate( + TextEditingValue oldValue, + TextEditingValue newValue, + ) { + if (newValue.text.length < 2) { + return newValue; + } + var value = int.parse(newValue.text); + final lastCharacter = newValue.text.characters.last; + if (value < minimum || value > maximum) { + return TextEditingValue( + text: lastCharacter, + selection: TextSelection.collapsed(offset: 1), + ); + } + return newValue; + } +} diff --git a/lib/core/widgets/form_fields/fullscreen_selection_form.dart b/lib/core/widgets/form_fields/fullscreen_selection_form.dart index b4d0297..514d75f 100644 --- a/lib/core/widgets/form_fields/fullscreen_selection_form.dart +++ b/lib/core/widgets/form_fields/fullscreen_selection_form.dart @@ -1,6 +1,6 @@ import 'package:flutter/material.dart'; import 'package:flutter/scheduler.dart'; -import 'package:paperless_mobile/extensions/flutter_extensions.dart'; +import 'package:paperless_mobile/core/extensions/flutter_extensions.dart'; import 'package:paperless_mobile/generated/l10n/app_localizations.dart'; class FullscreenSelectionForm extends StatefulWidget { diff --git a/lib/core/widgets/hint_card.dart b/lib/core/widgets/hint_card.dart index c041436..3f3583e 100644 --- a/lib/core/widgets/hint_card.dart +++ b/lib/core/widgets/hint_card.dart @@ -1,5 +1,5 @@ import 'package:flutter/material.dart'; -import 'package:paperless_mobile/extensions/flutter_extensions.dart'; +import 'package:paperless_mobile/core/extensions/flutter_extensions.dart'; import 'package:paperless_mobile/generated/l10n/app_localizations.dart'; class HintCard extends StatelessWidget { diff --git a/lib/core/widgets/paperless_logo.dart b/lib/core/widgets/paperless_logo.dart deleted file mode 100644 index e3bfb56..0000000 --- a/lib/core/widgets/paperless_logo.dart +++ /dev/null @@ -1,41 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:flutter_svg/flutter_svg.dart'; -import 'package:paperless_mobile/generated/assets.gen.dart'; - -class PaperlessLogo extends StatelessWidget { - static const _paperlessGreen = Color(0xFF18541F); - final double? height; - final double? width; - final Color _color; - - const PaperlessLogo.white({ - super.key, - this.height, - this.width, - }) : _color = Colors.white; - - const PaperlessLogo.green({super.key, this.height, this.width}) - : _color = _paperlessGreen; - - const PaperlessLogo.black({super.key, this.height, this.width}) - : _color = Colors.black; - - const PaperlessLogo.colored(Color color, {super.key, this.height, this.width}) - : _color = color; - - @override - Widget build(BuildContext context) { - return Container( - constraints: BoxConstraints( - maxHeight: height ?? Theme.of(context).iconTheme.size ?? 32, - maxWidth: width ?? Theme.of(context).iconTheme.size ?? 32, - ), - padding: const EdgeInsets.only(right: 8), - child: Assets.logos.paperlessLogoWhiteSvg.svg( - colorFilter: ColorFilter.mode( - _color, - BlendMode.srcIn, - ), - )); - } -} diff --git a/lib/features/app_drawer/view/app_drawer.dart b/lib/features/app_drawer/view/app_drawer.dart index ad7bed7..28e3877 100644 --- a/lib/features/app_drawer/view/app_drawer.dart +++ b/lib/features/app_drawer/view/app_drawer.dart @@ -2,13 +2,10 @@ import 'package:flutter/gestures.dart'; import 'package:flutter/material.dart'; import 'package:flutter_animate/flutter_animate.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; -import 'package:flutter_svg/flutter_svg.dart'; import 'package:paperless_mobile/constants.dart'; import 'package:paperless_mobile/core/database/tables/local_user_account.dart'; -import 'package:paperless_mobile/core/global/asset_images.dart'; -import 'package:paperless_mobile/core/widgets/hint_card.dart'; -import 'package:paperless_mobile/core/widgets/paperless_logo.dart'; -import 'package:paperless_mobile/extensions/flutter_extensions.dart'; +import 'package:paperless_mobile/features/logging/view/app_logs_page.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/saved_view/cubit/saved_view_cubit.dart'; import 'package:paperless_mobile/features/sharing/cubit/receive_share_cubit.dart'; @@ -18,6 +15,7 @@ import 'package:paperless_mobile/routes/typed/branches/documents_route.dart'; import 'package:paperless_mobile/routes/typed/branches/saved_views_route.dart'; import 'package:paperless_mobile/routes/typed/branches/upload_queue_route.dart'; import 'package:paperless_mobile/routes/typed/shells/authenticated_route.dart'; +import 'package:paperless_mobile/routes/typed/top_level/app_logs_route.dart'; import 'package:paperless_mobile/routes/typed/top_level/changelog_route.dart'; import 'package:paperless_mobile/routes/typed/top_level/settings_route.dart'; import 'package:provider/provider.dart'; @@ -39,10 +37,10 @@ class AppDrawer extends StatelessWidget { children: [ Row( children: [ - const PaperlessLogo.green( - width: 32, - height: 32, - ), + const $AssetsLogosGen() + .paperlessLogoGreenSvg + .svg(width: 32, height: 32), + SizedBox(width: 8), Text( "Paperless Mobile", style: Theme.of(context).textTheme.titleMedium, @@ -108,14 +106,6 @@ class AppDrawer extends StatelessWidget { ); }, ), - ListTile( - dense: true, - leading: const Icon(Icons.history), - title: Text(S.of(context)!.changelog), - onTap: () { - ChangelogRoute().push(context); - }, - ), ListTile( dense: true, leading: const Icon(Icons.bug_report_outlined), @@ -131,7 +121,7 @@ class AppDrawer extends StatelessWidget { ), onTap: () { launchUrlString( - 'https://github.com/astubenbord/paperless-mobile/issues/new', + 'https://github.com/astubenbord/paperless-mobile/issues/new?assignees=astubenbord&labels=bug%2Ctriage&projects=&template=bug-report.yml&title=%5BBug%5D%3A+', mode: LaunchMode.externalApplication, ); }, diff --git a/lib/features/changelogs/view/changelog_dialog.dart b/lib/features/changelogs/view/changelog_dialog.dart index 8aa6fec..bad8155 100644 --- a/lib/features/changelogs/view/changelog_dialog.dart +++ b/lib/features/changelogs/view/changelog_dialog.dart @@ -2,7 +2,7 @@ import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:flutter_markdown/flutter_markdown.dart'; import 'package:go_router/go_router.dart'; -import 'package:paperless_mobile/extensions/flutter_extensions.dart'; +import 'package:paperless_mobile/core/extensions/flutter_extensions.dart'; import 'package:paperless_mobile/generated/l10n/app_localizations.dart'; import 'package:paperless_mobile/theme.dart'; @@ -63,6 +63,7 @@ class ChangelogDialog extends StatelessWidget { } const _versionNumbers = { + "54": "3.0.7", "53": "3.0.6", "52": "3.0.5", "51": "3.0.4", diff --git a/lib/features/document_bulk_action/view/widgets/fullscreen_bulk_edit_label_page.dart b/lib/features/document_bulk_action/view/widgets/fullscreen_bulk_edit_label_page.dart index 33dee5e..cf26f5f 100644 --- a/lib/features/document_bulk_action/view/widgets/fullscreen_bulk_edit_label_page.dart +++ b/lib/features/document_bulk_action/view/widgets/fullscreen_bulk_edit_label_page.dart @@ -3,7 +3,7 @@ import 'package:flutter/material.dart'; import 'package:go_router/go_router.dart'; import 'package:paperless_api/paperless_api.dart'; import 'package:paperless_mobile/core/widgets/form_fields/fullscreen_selection_form.dart'; -import 'package:paperless_mobile/extensions/dart_extensions.dart'; +import 'package:paperless_mobile/core/extensions/dart_extensions.dart'; import 'package:paperless_mobile/features/document_bulk_action/view/widgets/confirm_bulk_modify_label_dialog.dart'; import 'package:paperless_mobile/generated/l10n/app_localizations.dart'; diff --git a/lib/features/document_bulk_action/view/widgets/fullscreen_bulk_edit_tags_widget.dart b/lib/features/document_bulk_action/view/widgets/fullscreen_bulk_edit_tags_widget.dart index dbeb107..52dc103 100644 --- a/lib/features/document_bulk_action/view/widgets/fullscreen_bulk_edit_tags_widget.dart +++ b/lib/features/document_bulk_action/view/widgets/fullscreen_bulk_edit_tags_widget.dart @@ -4,7 +4,7 @@ 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/widgets/form_fields/fullscreen_selection_form.dart'; -import 'package:paperless_mobile/extensions/dart_extensions.dart'; +import 'package:paperless_mobile/core/extensions/dart_extensions.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/confirm_bulk_modify_tags_dialog.dart'; import 'package:paperless_mobile/generated/l10n/app_localizations.dart'; diff --git a/lib/features/document_details/cubit/document_details_cubit.dart b/lib/features/document_details/cubit/document_details_cubit.dart index 30f0eba..466d911 100644 --- a/lib/features/document_details/cubit/document_details_cubit.dart +++ b/lib/features/document_details/cubit/document_details_cubit.dart @@ -2,22 +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/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; @@ -28,43 +29,52 @@ class DocumentDetailsCubit extends Cubit { this._labelRepository, this._notifier, this._notificationService, { - required DocumentModel initialDocument, - }) : super(DocumentDetailsState( - document: initialDocument, - )) { - _notifier.addListener(this, onUpdated: replace); - _labelRepository.addListener( + required this.id, + }) : super(const DocumentDetailsInitial()) { + _notifier.addListener( this, - onChanged: (labels) => emit( - state.copyWith( - correspondents: labels.correspondents, - documentTypes: labels.documentTypes, - tags: labels.tags, - storagePaths: labels.storagePaths, - ), - ), + onUpdated: (document) { + replace(document); + }, + ids: [id], ); } + 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 { await _api.delete(document); _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, @@ -84,11 +94,15 @@ class DocumentDetailsCubit extends Cubit { } Future openDocumentInSystemViewer() async { - final cacheDir = await FileService.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"); @@ -96,7 +110,7 @@ class DocumentDetailsCubit extends Cubit { if (!file.existsSync()) { file.createSync(); await _api.downloadToFile( - state.document, + s.document, file.path, ); } @@ -107,7 +121,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({ @@ -115,19 +136,21 @@ 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, - await FileService.downloadsDirectory, + FileService.instance.downloadsDirectory, ); if (!await File(targetPath).exists()) { await File(targetPath).create(); } else { - await _notificationService.notifyFileDownload( - document: state.document, + await _notificationService.notifyDocumentDownload( + document: s.document, filename: p.basename(targetPath), filePath: targetPath, finished: true, @@ -146,12 +169,12 @@ class DocumentDetailsCubit extends Cubit { // ); await _api.downloadToFile( - state.document, + s.document, targetPath, original: downloadOriginal, onProgressChanged: (progress) { - _notificationService.notifyFileDownload( - document: state.document, + _notificationService.notifyDocumentDownload( + document: s.document, filename: p.basename(targetPath), filePath: targetPath, finished: true, @@ -161,27 +184,29 @@ class DocumentDetailsCubit extends Cubit { ); }, ); - await _notificationService.notifyFileDownload( - document: state.document, + await _notificationService.notifyDocumentDownload( + document: s.document, filename: p.basename(targetPath), filePath: targetPath, finished: true, locale: locale, userId: userId, ); - debugPrint("Downloaded file 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, - await FileService.temporaryDirectory, + FileService.instance.temporaryDirectory, ); await _api.downloadToFile( - state.document, + s.document, filePath, original: shareOriginal, ); @@ -189,23 +214,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, await FileService.temporaryDirectory); + final filePath = _buildDownloadFilePath( + s.metaData, + false, + FileService.instance.temporaryDirectory, + ); await _api.downloadToFile( - state.document, + s.document, filePath, original: false, ); @@ -214,13 +243,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 57460c1..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/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 ba2f6e2..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 @@ -4,7 +4,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/extensions/flutter_extensions.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'; import 'package:paperless_mobile/helpers/message_helpers.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 6516658..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/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_download_button.dart b/lib/features/document_details/view/widgets/document_download_button.dart index 5f66073..abbf996 100644 --- a/lib/features/document_details/view/widgets/document_download_button.dart +++ b/lib/features/document_details/view/widgets/document_download_button.dart @@ -3,9 +3,9 @@ import 'dart:io'; import 'package:flutter/material.dart'; import 'package:hive/hive.dart'; import 'package:paperless_api/paperless_api.dart'; -import 'package:paperless_mobile/core/config/hive/hive_config.dart'; +import 'package:paperless_mobile/core/database/hive/hive_config.dart'; import 'package:paperless_mobile/core/database/tables/local_user_account.dart'; -import 'package:paperless_mobile/extensions/flutter_extensions.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/dialogs/select_file_type_dialog.dart'; import 'package:paperless_mobile/core/database/tables/global_settings.dart'; 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 3c45a44..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 @@ -3,88 +3,74 @@ import 'package:flutter_bloc/flutter_bloc.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/extensions/flutter_extensions.dart'; -import 'package:paperless_mobile/features/document_details/cubit/document_details_cubit.dart'; +import 'package:paperless_mobile/core/extensions/flutter_extensions.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 369cdf2..c6d255d 100644 --- a/lib/features/document_details/view/widgets/document_overview_widget.dart +++ b/lib/features/document_details/view/widgets/document_overview_widget.dart @@ -5,7 +5,8 @@ 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/highlighted_text.dart'; -import 'package:paperless_mobile/extensions/flutter_extensions.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_details/view/widgets/document_share_button.dart b/lib/features/document_details/view/widgets/document_share_button.dart index aaeb0c0..9ab0e4d 100644 --- a/lib/features/document_details/view/widgets/document_share_button.dart +++ b/lib/features/document_details/view/widgets/document_share_button.dart @@ -4,9 +4,9 @@ import 'package:flutter/material.dart'; import 'package:hive_flutter/hive_flutter.dart'; import 'package:paperless_api/paperless_api.dart'; import 'package:paperless_mobile/constants.dart'; -import 'package:paperless_mobile/core/config/hive/hive_config.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/extensions/flutter_extensions.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/dialogs/select_file_type_dialog.dart'; import 'package:paperless_mobile/features/settings/model/file_download_type.dart'; diff --git a/lib/features/document_edit/cubit/document_edit_cubit.dart b/lib/features/document_edit/cubit/document_edit_cubit.dart index 3003264..839bf98 100644 --- a/lib/features/document_edit/cubit/document_edit_cubit.dart +++ b/lib/features/document_edit/cubit/document_edit_cubit.dart @@ -22,22 +22,12 @@ class DocumentEditCubit extends Cubit { required DocumentModel document, }) : _initialDocument = document, super(DocumentEditState(document: document)) { - _notifier.addListener(this, onUpdated: replace); - _labelRepository.addListener( + _notifier.addListener( this, - onChanged: (labels) { - if (isClosed) { - return; - } - emit( - state.copyWith( - correspondents: labels.correspondents, - documentTypes: labels.documentTypes, - storagePaths: labels.storagePaths, - tags: labels.tags, - ), - ); + onUpdated: (doc) { + emit(state.copyWith(document: doc)); }, + ids: [document.id], ); } @@ -69,14 +59,9 @@ class DocumentEditCubit extends Cubit { emit(state.copyWith(suggestions: suggestions)); } - void replace(DocumentModel document) { - emit(state.copyWith(document: document)); - } - @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 2b51c97..18a7e32 100644 --- a/lib/features/document_edit/view/document_edit_page.dart +++ b/lib/features/document_edit/view/document_edit_page.dart @@ -2,7 +2,6 @@ import 'dart:async'; import 'package:collection/collection.dart'; import 'package:flutter/material.dart'; -import 'package:flutter/services.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:flutter_form_builder/flutter_form_builder.dart'; import 'package:flutter_typeahead/flutter_typeahead.dart'; @@ -10,18 +9,19 @@ 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/widgets/dialog_utils/dialog_cancel_button.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/dialog_utils/pop_with_unsaved_changes.dart'; +import 'package:paperless_mobile/core/widgets/form_builder_fields/form_builder_localized_date_picker.dart'; import 'package:paperless_mobile/core/workarounds/colored_chip.dart'; -import 'package:paperless_mobile/extensions/flutter_extensions.dart'; import 'package:paperless_mobile/features/document_edit/cubit/document_edit_cubit.dart'; +import 'package:paperless_mobile/features/documents/view/pages/document_view.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/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:paperless_mobile/theme.dart'; class DocumentEditPage extends StatefulWidget { const DocumentEditPage({ @@ -32,7 +32,8 @@ class DocumentEditPage extends StatefulWidget { State createState() => _DocumentEditPageState(); } -class _DocumentEditPageState extends State { +class _DocumentEditPageState extends State + with SingleTickerProviderStateMixin { static const fkTitle = "title"; static const fkCorrespondent = "correspondent"; static const fkTags = "tags"; @@ -43,42 +44,27 @@ class _DocumentEditPageState extends State { final _formKey = GlobalKey(); + bool _isShowingPdf = false; + + late final AnimationController _animationController; + late final Animation _animation; + + @override + void initState() { + super.initState(); + _animationController = AnimationController( + duration: const Duration(milliseconds: 150), + vsync: this, + ); + _animation = + CurvedAnimation(parent: _animationController, curve: Curves.easeInCubic) + .drive(Tween(begin: 0, end: 1)); + } + @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( @@ -107,212 +93,226 @@ class _DocumentEditPageState extends State { doc.created != createdAt || (doc.content != content && isContentTouched); }, - child: DefaultTabController( - length: 2, + child: FormBuilder( + key: _formKey, child: Scaffold( - resizeToAvoidBottomInset: false, - floatingActionButton: FloatingActionButton.extended( - heroTag: "fab_document_edit", - onPressed: () => _onSubmit(state.document), - icon: const Icon(Icons.save), - label: Text(S.of(context)!.saveChanges), - ), - appBar: AppBar( - title: Text(S.of(context)!.editDocument), - bottom: TabBar( - tabs: [ - Tab(text: S.of(context)!.overview), - Tab(text: S.of(context)!.content) - ], - ), - ), - extendBody: true, - body: Padding( - padding: EdgeInsets.only( - bottom: MediaQuery.of(context).viewInsets.bottom, - top: 8, - left: 8, - right: 8, - ), - child: FormBuilder( - key: _formKey, - child: TabBarView( - children: [ - ListView( - children: [ - _buildTitleFormField(state.document.title).padded(), - _buildCreatedAtFormField( - state.document.created, - filteredSuggestions, - ).padded(), - // Correspondent form field - if (currentUser.canViewCorrespondents) - Column( - children: [ - LabelFormField( - showAnyAssignedOption: false, - showNotAssignedOption: false, - onAddLabel: (currentInput) => - CreateLabelRoute( - LabelType.correspondent, - name: currentInput, - ).push(context), - addLabelText: - S.of(context)!.addCorrespondent, - labelText: S.of(context)!.correspondent, - options: context - .watch() - .state - .correspondents, - initialValue: state - .document.correspondent != - null - ? SetIdQueryParameter( - id: state.document.correspondent!) - : const UnsetIdQueryParameter(), - name: fkCorrespondent, - prefixIcon: - const Icon(Icons.person_outlined), - allowSelectUnassigned: true, - canCreateNewLabel: - currentUser.canCreateCorrespondents, - suggestions: - filteredSuggestions?.correspondents ?? - [], - ), - ], - ).padded(), - // DocumentType form field - if (currentUser.canViewDocumentTypes) - Column( - children: [ - LabelFormField( - showAnyAssignedOption: false, - showNotAssignedOption: false, - onAddLabel: (currentInput) => - CreateLabelRoute( - LabelType.documentType, - name: currentInput, - ).push(context), - canCreateNewLabel: - currentUser.canCreateDocumentTypes, - addLabelText: - S.of(context)!.addDocumentType, - labelText: S.of(context)!.documentType, - initialValue: state.document.documentType != - null - ? SetIdQueryParameter( - id: state.document.documentType!) - : const UnsetIdQueryParameter(), - options: state.documentTypes, - name: _DocumentEditPageState.fkDocumentType, - prefixIcon: - const Icon(Icons.description_outlined), - allowSelectUnassigned: true, - suggestions: - filteredSuggestions?.documentTypes ?? - [], - ), - ], - ).padded(), - // StoragePath form field - if (currentUser.canViewStoragePaths) - Column( - children: [ - LabelFormField( - showAnyAssignedOption: false, - showNotAssignedOption: false, - onAddLabel: (currentInput) => - CreateLabelRoute( - LabelType.storagePath, - name: currentInput, - ).push(context), - canCreateNewLabel: - currentUser.canCreateStoragePaths, - addLabelText: S.of(context)!.addStoragePath, - labelText: S.of(context)!.storagePath, - options: state.storagePaths, - initialValue: - state.document.storagePath != null - ? SetIdQueryParameter( - id: state.document.storagePath!) - : const UnsetIdQueryParameter(), - name: fkStoragePath, - prefixIcon: - const Icon(Icons.folder_outlined), - allowSelectUnassigned: true, - ), - ], - ).padded(), - // Tag form field - if (currentUser.canViewTags) - TagsFormField( - options: state.tags, - name: fkTags, - allowOnlySelection: true, - allowCreation: true, - allowExclude: false, - suggestions: filteredSuggestions?.tags ?? [], - initialValue: IdsTagsQuery( - include: state.document.tags.toList(), - ), - ).padded(), - if (filteredSuggestions?.tags - .toSet() - .difference(state.document.tags.toSet()) - .isNotEmpty ?? - false) - const SizedBox(height: 64), - ], - ), - SingleChildScrollView( - child: Column( - children: [ - FormBuilderTextField( - name: fkContent, - maxLines: null, - keyboardType: TextInputType.multiline, - initialValue: state.document.content, - decoration: const InputDecoration( - border: InputBorder.none, - ), - ), - const SizedBox(height: 84), - ], - ), - ), - ], + appBar: AppBar( + title: Text(S.of(context)!.editDocument), + actions: [ + IconButton( + tooltip: _isShowingPdf + ? S.of(context)!.hidePdf + : S.of(context)!.showPdf, + padding: EdgeInsets.all(12), + icon: AnimatedCrossFade( + duration: _animationController.duration!, + reverseDuration: _animationController.reverseDuration, + crossFadeState: _isShowingPdf + ? CrossFadeState.showFirst + : CrossFadeState.showSecond, + firstChild: Icon(Icons.visibility_off_outlined), + secondChild: Icon(Icons.visibility_outlined), + ), + onPressed: () { + if (_isShowingPdf) { + setState(() { + _isShowingPdf = false; + }); + _animationController.reverse(); + } else { + setState(() { + _isShowingPdf = true; + }); + _animationController.forward(); + } + }, + ) + ], + ), + body: Stack( + children: [ + DefaultTabController( + length: 2, + child: Scaffold( + resizeToAvoidBottomInset: true, + floatingActionButton: !_isShowingPdf + ? FloatingActionButton.extended( + heroTag: "fab_document_edit", + onPressed: () => _onSubmit(state.document), + icon: const Icon(Icons.save), + label: Text(S.of(context)!.saveChanges), + ) + : null, + appBar: TabBar( + tabs: [ + Tab(text: S.of(context)!.overview), + Tab(text: S.of(context)!.content), + ], + ), + extendBody: true, + body: _buildEditForm( + context, + state, + filteredSuggestions, + currentUser, + ), ), ), - )), + AnimatedBuilder( + animation: _animation, + builder: (context, child) { + return Transform.scale( + alignment: Alignment.bottomLeft, + scale: _animation.value, + child: DocumentView( + showAppBar: false, + showControls: false, + documentBytes: context + .read() + .downloadDocument(state.document.id), + ), + ); + }, + ), + ], + ), + ), ), ); }, ); } - 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; + Padding _buildEditForm(BuildContext context, DocumentEditState state, + FieldSuggestions? filteredSuggestions, UserModel currentUser) { + return Padding( + padding: const EdgeInsets.symmetric(horizontal: 8), + child: TabBarView( + physics: NeverScrollableScrollPhysics(), + children: [ + ListView( + children: [ + SizedBox(height: 16), + _buildTitleFormField(state.document.title).padded(), + _buildCreatedAtFormField( + state.document.created, + filteredSuggestions, + ).padded(), + // Correspondent form field + if (currentUser.canViewCorrespondents) + Column( + children: [ + LabelFormField( + showAnyAssignedOption: false, + showNotAssignedOption: false, + onAddLabel: (currentInput) => CreateLabelRoute( + LabelType.correspondent, + name: currentInput, + ).push(context), + addLabelText: S.of(context)!.addCorrespondent, + labelText: S.of(context)!.correspondent, + options: + context.watch().state.correspondents, + initialValue: state.document.correspondent != null + ? SetIdQueryParameter( + id: state.document.correspondent!) + : const UnsetIdQueryParameter(), + name: fkCorrespondent, + prefixIcon: const Icon(Icons.person_outlined), + allowSelectUnassigned: true, + canCreateNewLabel: currentUser.canCreateCorrespondents, + suggestions: filteredSuggestions?.correspondents ?? [], + ), + ], + ).padded(), + // DocumentType form field + if (currentUser.canViewDocumentTypes) + Column( + children: [ + LabelFormField( + showAnyAssignedOption: false, + showNotAssignedOption: false, + onAddLabel: (currentInput) => CreateLabelRoute( + LabelType.documentType, + name: currentInput, + ).push(context), + canCreateNewLabel: currentUser.canCreateDocumentTypes, + addLabelText: S.of(context)!.addDocumentType, + labelText: S.of(context)!.documentType, + initialValue: state.document.documentType != null + ? SetIdQueryParameter( + id: state.document.documentType!) + : const UnsetIdQueryParameter(), + options: + context.watch().state.documentTypes, + name: _DocumentEditPageState.fkDocumentType, + prefixIcon: const Icon(Icons.description_outlined), + allowSelectUnassigned: true, + suggestions: filteredSuggestions?.documentTypes ?? [], + ), + ], + ).padded(), + // StoragePath form field + if (currentUser.canViewStoragePaths) + Column( + children: [ + LabelFormField( + showAnyAssignedOption: false, + showNotAssignedOption: false, + onAddLabel: (currentInput) => CreateLabelRoute( + LabelType.storagePath, + name: currentInput, + ).push(context), + canCreateNewLabel: currentUser.canCreateStoragePaths, + addLabelText: S.of(context)!.addStoragePath, + labelText: S.of(context)!.storagePath, + options: + context.watch().state.storagePaths, + initialValue: state.document.storagePath != null + ? SetIdQueryParameter(id: state.document.storagePath!) + : const UnsetIdQueryParameter(), + name: fkStoragePath, + prefixIcon: const Icon(Icons.folder_outlined), + allowSelectUnassigned: true, + ), + ], + ).padded(), + // Tag form field + if (currentUser.canViewTags) + TagsFormField( + options: context.watch().state.tags, + name: fkTags, + allowOnlySelection: true, + allowCreation: true, + allowExclude: false, + suggestions: filteredSuggestions?.tags ?? [], + initialValue: IdsTagsQuery( + include: state.document.tags.toList(), + ), + ).padded(), + + const SizedBox(height: 140), + ], + ), + SingleChildScrollView( + child: Column( + children: [ + FormBuilderTextField( + name: fkContent, + maxLines: null, + keyboardType: TextInputType.multiline, + initialValue: state.document.content, + decoration: const InputDecoration( + border: InputBorder.none, + ), + ), + const SizedBox(height: 84), + ], + ), + ), + ], + ), + ); } ( @@ -334,7 +334,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, @@ -359,7 +359,7 @@ class _DocumentEditPageState extends State { documentType, storagePath, tags, - created, + created?.toDateTime(), content ); } @@ -401,6 +401,12 @@ class _DocumentEditPageState extends State { name: fkTitle, decoration: InputDecoration( label: Text(S.of(context)!.title), + suffixIcon: IconButton( + icon: Icon(Icons.clear), + onPressed: () { + _formKey.currentState?.fields[fkTitle]?.didChange(null); + }, + ), ), initialValue: initialTitle, ); @@ -409,18 +415,15 @@ class _DocumentEditPageState extends State { Widget _buildCreatedAtFormField( DateTime? initialCreatedAtDate, FieldSuggestions? filteredSuggestions) { return Column( - crossAxisAlignment: CrossAxisAlignment.start, children: [ - FormBuilderDateTimePicker( - inputType: InputType.date, + FormBuilderLocalizedDatePicker( name: fkCreatedDate, - decoration: InputDecoration( - prefixIcon: const Icon(Icons.calendar_month_outlined), - label: Text(S.of(context)!.createdAt), - ), initialValue: initialCreatedAtDate, - format: DateFormat.yMMMMd(Localizations.localeOf(context).toString()), - initialEntryMode: DatePickerEntryMode.calendar, + labelText: S.of(context)!.createdAt, + firstDate: DateTime(1970, 1, 1), + lastDate: DateTime.now(), + locale: Localizations.localeOf(context), + prefixIcon: Icon(Icons.calendar_today), ), if (filteredSuggestions?.hasSuggestedDates ?? false) _buildSuggestionsSkeleton( @@ -430,7 +433,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_scan/cubit/document_scanner_cubit.dart b/lib/features/document_scan/cubit/document_scanner_cubit.dart index de54d25..85b63d7 100644 --- a/lib/features/document_scan/cubit/document_scanner_cubit.dart +++ b/lib/features/document_scan/cubit/document_scanner_cubit.dart @@ -4,6 +4,7 @@ import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:paperless_api/paperless_api.dart'; +import 'package:paperless_mobile/features/logging/data/logger.dart'; import 'package:paperless_mobile/core/model/info_message_exception.dart'; import 'package:paperless_mobile/core/service/file_service.dart'; import 'package:paperless_mobile/features/notifications/services/local_notification_service.dart'; @@ -18,13 +19,21 @@ class DocumentScannerCubit extends Cubit { : super(const InitialDocumentScannerState()); Future initialize() async { - debugPrint("Restoring scans..."); + logger.fd( + "Restoring scans...", + className: runtimeType.toString(), + methodName: "initialize", + ); emit(const RestoringDocumentScannerState()); - final tempDir = await FileService.temporaryScansDirectory; + final tempDir = FileService.instance.temporaryScansDirectory; final allFiles = tempDir.list().whereType(); final scans = await allFiles.where((event) => event.path.endsWith(".jpeg")).toList(); - debugPrint("Restored ${scans.length} scans."); + logger.fd( + "Restored ${scans.length} scans.", + className: runtimeType.toString(), + methodName: "initialize", + ); emit( scans.isEmpty ? const InitialDocumentScannerState() @@ -74,7 +83,7 @@ class DocumentScannerCubit extends Cubit { String fileName, String locale, ) async { - var file = await FileService.saveToFile(bytes, fileName); + var file = await FileService.instance.saveToFile(bytes, fileName); _notificationService.notifyFileSaved( filename: fileName, filePath: file.path, diff --git a/lib/features/document_scan/view/scanner_page.dart b/lib/features/document_scan/view/scanner_page.dart index d44f623..a0317be 100644 --- a/lib/features/document_scan/view/scanner_page.dart +++ b/lib/features/document_scan/view/scanner_page.dart @@ -10,7 +10,7 @@ import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:hive/hive.dart'; import 'package:paperless_api/paperless_api.dart'; import 'package:paperless_mobile/constants.dart'; -import 'package:paperless_mobile/core/config/hive/hive_config.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/global/constants.dart'; import 'package:paperless_mobile/core/service/file_service.dart'; @@ -227,9 +227,10 @@ class _ScannerPageState extends State if (!isGranted) { return; } - final file = await FileService.allocateTemporaryFile( + final file = await FileService.instance.allocateTemporaryFile( PaperlessDirectoryType.scans, extension: 'jpeg', + create: true, ); if (kDebugMode) { dev.log('[ScannerPage] Created temporary file: ${file.path}'); diff --git a/lib/features/document_search/view/document_search_bar.dart b/lib/features/document_search/view/document_search_bar.dart index bbc3bd5..43b81ef 100644 --- a/lib/features/document_search/view/document_search_bar.dart +++ b/lib/features/document_search/view/document_search_bar.dart @@ -1,7 +1,7 @@ import 'package:animations/animations.dart'; import 'package:flutter/material.dart'; import 'package:hive_flutter/adapters.dart'; -import 'package:paperless_mobile/core/config/hive/hive_config.dart'; +import 'package:paperless_mobile/core/database/hive/hive_config.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/features/document_search/cubit/document_search_cubit.dart'; diff --git a/lib/features/document_search/view/document_search_page.dart b/lib/features/document_search/view/document_search_page.dart index b2067f8..83b96dc 100644 --- a/lib/features/document_search/view/document_search_page.dart +++ b/lib/features/document_search/view/document_search_page.dart @@ -4,7 +4,8 @@ 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/extensions/flutter_extensions.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'; import 'package:paperless_mobile/features/documents/view/widgets/adaptive_documents_view.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_search/view/sliver_search_bar.dart b/lib/features/document_search/view/sliver_search_bar.dart index 7d83861..2b87c87 100644 --- a/lib/features/document_search/view/sliver_search_bar.dart +++ b/lib/features/document_search/view/sliver_search_bar.dart @@ -1,7 +1,7 @@ import 'package:flutter/material.dart'; import 'package:hive_flutter/adapters.dart'; import 'package:paperless_api/paperless_api.dart'; -import 'package:paperless_mobile/core/config/hive/hive_config.dart'; +import 'package:paperless_mobile/core/database/hive/hive_config.dart'; import 'package:paperless_mobile/core/database/tables/local_user_account.dart'; import 'package:paperless_mobile/features/document_search/view/document_search_bar.dart'; import 'package:paperless_mobile/features/settings/view/manage_accounts_page.dart'; 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 5770464..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,27 +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/config/hive/hive_config.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/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/extensions/flutter_extensions.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; @@ -61,7 +58,6 @@ class _DocumentUploadPreparationPageState final GlobalKey _formKey = GlobalKey(); Map _errors = {}; - bool _isUploadLoading = false; late bool _syncTitleAndFilename; bool _showDatePickerDeleteIcon = false; final _now = DateTime.now(); @@ -74,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) => [ @@ -96,7 +103,7 @@ class _DocumentUploadPreparationPageState handle: NestedScrollView.sliverOverlapAbsorberHandleFor(context), sliver: SliverAppBar( - leading: BackButton(), + leading: const BackButton(), pinned: true, expandedHeight: 150, flexibleSpace: FlexibleSpaceBar( @@ -104,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!, @@ -116,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, ), ), ], @@ -218,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 @@ -260,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 @@ -283,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, @@ -301,7 +283,7 @@ class _DocumentUploadPreparationPageState allowCreation: true, allowExclude: false, allowOnlySelection: true, - options: state.tags, + options: labels.tags, ), Text( "* " + S.of(context)!.uploadInferValuesHint, @@ -317,9 +299,9 @@ class _DocumentUploadPreparationPageState ), ), ), - ); - }, - ), + ), + ); + }, ); } @@ -327,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 = @@ -335,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, @@ -364,7 +345,7 @@ class _DocumentUploadPreparationPageState documentType: docType, correspondent: correspondent, tags: tags, - createdAt: createdAt, + createdAt: createdAt?.toDateTime(), asn: asn, ); showSnackBar( @@ -376,14 +357,19 @@ class _DocumentUploadPreparationPageState showErrorMessage(context, error, stackTrace); } on PaperlessFormValidationException catch (exception) { setState(() => _errors = exception.validationMessages); - } catch (unknownError, stackTrace) { - debugPrint(unknownError.toString()); + } catch (error, stackTrace) { + logger.fe( + "An unknown error occurred during document upload.", + className: runtimeType.toString(), + methodName: "_onSubmit", + error: error, + stackTrace: stackTrace, + ); showErrorMessage( - context, const PaperlessApiException.unknown(), stackTrace); - } finally { - setState(() { - _isUploadLoading = false; - }); + context, + const PaperlessApiException.unknown(), + stackTrace, + ); } } } diff --git a/lib/features/documents/cubit/documents_cubit.dart b/lib/features/documents/cubit/documents_cubit.dart index b5fb1a8..3103b57 100644 --- a/lib/features/documents/cubit/documents_cubit.dart +++ b/lib/features/documents/cubit/documents_cubit.dart @@ -5,6 +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_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'; @@ -44,18 +45,15 @@ class DocumentsCubit extends Cubit replace(document); emit( state.copyWith( - selection: state.selection - .map((e) => e.id == document.id ? document : e) - .toList(), - ), + selection: + state.selection.withDocumentreplaced(document).toList()), ); }, onDeleted: (document) { remove(document); emit( state.copyWith( - selection: - state.selection.where((e) => e.id != document.id).toList(), + selection: state.selection.withDocumentRemoved(document).toList(), ), ); }, @@ -74,7 +72,6 @@ class DocumentsCubit extends Cubit } Future bulkDelete(List documents) async { - debugPrint("[DocumentsCubit] bulkRemove"); await api.bulkAction( BulkDeleteAction(documents.map((doc) => doc.id)), ); @@ -85,7 +82,6 @@ class DocumentsCubit extends Cubit } void toggleDocumentSelection(DocumentModel model) { - debugPrint("[DocumentsCubit] toggleSelection"); if (state.selectedIds.contains(model.id)) { emit( state.copyWith( @@ -100,12 +96,10 @@ class DocumentsCubit extends Cubit } void resetSelection() { - debugPrint("[DocumentsCubit] resetSelection"); emit(state.copyWith(selection: [])); } void reset() { - debugPrint("[DocumentsCubit] reset"); emit(const DocumentsState()); } diff --git a/lib/features/documents/view/pages/document_view.dart b/lib/features/documents/view/pages/document_view.dart index 19d4fb9..bda53a1 100644 --- a/lib/features/documents/view/pages/document_view.dart +++ b/lib/features/documents/view/pages/document_view.dart @@ -5,9 +5,13 @@ import 'package:flutter_pdfview/flutter_pdfview.dart'; class DocumentView extends StatefulWidget { final Future documentBytes; final String? title; + final bool showAppBar; + final bool showControls; const DocumentView({ Key? key, required this.documentBytes, + this.showAppBar = true, + this.showControls = true, this.title, }) : super(key: key); @@ -27,43 +31,47 @@ class _DocumentViewState extends State { final canGoToNextPage = isInitialized && _currentPage! + 1 < _totalPages!; final canGoToPreviousPage = isInitialized && _currentPage! > 0; return Scaffold( - appBar: AppBar( - title: widget.title != null ? Text(widget.title!) : null, - ), - bottomNavigationBar: BottomAppBar( - child: Row( - children: [ - Flexible( + appBar: widget.showAppBar + ? AppBar( + title: widget.title != null ? Text(widget.title!) : null, + ) + : null, + bottomNavigationBar: widget.showControls + ? BottomAppBar( child: Row( children: [ - IconButton.filled( - onPressed: canGoToPreviousPage - ? () { - _controller?.setPage(_currentPage! - 1); - } - : null, - icon: const Icon(Icons.arrow_left), - ), - const SizedBox(width: 16), - IconButton.filled( - onPressed: canGoToNextPage - ? () { - _controller?.setPage(_currentPage! + 1); - } - : null, - icon: const Icon(Icons.arrow_right), + Flexible( + child: Row( + children: [ + IconButton.filled( + onPressed: canGoToPreviousPage + ? () { + _controller?.setPage(_currentPage! - 1); + } + : null, + icon: const Icon(Icons.arrow_left), + ), + const SizedBox(width: 16), + IconButton.filled( + onPressed: canGoToNextPage + ? () { + _controller?.setPage(_currentPage! + 1); + } + : null, + icon: const Icon(Icons.arrow_right), + ), + ], + ), ), + if (_currentPage != null && _totalPages != null) + Text( + "${_currentPage! + 1}/$_totalPages", + style: Theme.of(context).textTheme.labelLarge, + ), ], ), - ), - if (_currentPage != null && _totalPages != null) - Text( - "${_currentPage! + 1}/$_totalPages", - style: Theme.of(context).textTheme.labelLarge, - ), - ], - ), - ), + ) + : null, body: FutureBuilder( future: widget.documentBytes, builder: (context, snapshot) { @@ -93,12 +101,7 @@ class _DocumentViewState extends State { onViewCreated: (controller) { _controller = controller; }, - onError: (error) { - print(error.toString()); - }, - onPageError: (page, error) { - print('$page: ${error.toString()}'); - }, + ); }), ); diff --git a/lib/features/documents/view/pages/documents_page.dart b/lib/features/documents/view/pages/documents_page.dart index 0f31032..addcfad 100644 --- a/lib/features/documents/view/pages/documents_page.dart +++ b/lib/features/documents/view/pages/documents_page.dart @@ -6,7 +6,8 @@ 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/extensions/flutter_extensions.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'; import 'package:paperless_mobile/features/documents/cubit/documents_cubit.dart'; @@ -109,7 +110,7 @@ class _DocumentsPageState extends State { } void _scrollExtentChangedListener() { - const threshold = 400; + const threshold = kToolbarHeight * 2; final offset = _nestedScrollViewKey.currentState!.innerController.position.pixels; if (offset < threshold && _showExtendedFab == false) { @@ -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, @@ -424,6 +429,9 @@ class _DocumentsPageState extends State { ); }, ), + const SliverToBoxAdapter( + child: SizedBox(height: 96), + ) ], ), ), diff --git a/lib/features/documents/view/widgets/adaptive_documents_view.dart b/lib/features/documents/view/widgets/adaptive_documents_view.dart index 4c861e8..f9e4de3 100644 --- a/lib/features/documents/view/widgets/adaptive_documents_view.dart +++ b/lib/features/documents/view/widgets/adaptive_documents_view.dart @@ -1,5 +1,6 @@ import 'package:flutter/material.dart'; import 'package:paperless_api/paperless_api.dart'; +import 'package:paperless_mobile/core/extensions/flutter_extensions.dart'; import 'package:paperless_mobile/features/documents/view/widgets/placeholder/document_grid_loading_widget.dart'; import 'package:paperless_mobile/features/documents/view/widgets/items/document_detailed_item.dart'; import 'package:paperless_mobile/features/documents/view/widgets/items/document_grid_item.dart'; @@ -159,7 +160,7 @@ class SliverAdaptiveDocumentsView extends AdaptiveDocumentsView { crossAxisCount: 2, mainAxisSpacing: 4, crossAxisSpacing: 4, - mainAxisExtent: 356, + mainAxisExtent: 324, ), itemCount: documents.length, itemBuilder: (context, index) { @@ -176,7 +177,7 @@ class SliverAdaptiveDocumentsView extends AdaptiveDocumentsView { onDocumentTypeSelected: onDocumentTypeSelected, onStoragePathSelected: onStoragePathSelected, enableHeroAnimation: enableHeroAnimation, - ); + ).paddedSymmetrically(horizontal: 4); }, ); } diff --git a/lib/features/documents/view/widgets/date_and_document_type_widget.dart b/lib/features/documents/view/widgets/date_and_document_type_widget.dart new file mode 100644 index 0000000..f9da206 --- /dev/null +++ b/lib/features/documents/view/widgets/date_and_document_type_widget.dart @@ -0,0 +1,55 @@ +import 'package:flutter/material.dart'; +import 'package:intl/intl.dart'; +import 'package:paperless_api/paperless_api.dart'; +import 'package:paperless_mobile/core/repository/label_repository.dart'; +import 'package:provider/provider.dart'; + +class DateAndDocumentTypeLabelWidget extends StatelessWidget { + const DateAndDocumentTypeLabelWidget({ + super.key, + required this.document, + required this.onDocumentTypeSelected, + }); + + final DocumentModel document; + final void Function(int? documentTypeId)? onDocumentTypeSelected; + + @override + Widget build(BuildContext context) { + final subtitleStyle = + Theme.of(context).textTheme.labelMedium?.apply(color: Colors.grey); + return RichText( + maxLines: 1, + overflow: TextOverflow.ellipsis, + text: TextSpan( + text: DateFormat.yMMMMd(Localizations.localeOf(context).toString()) + .format(document.created), + style: subtitleStyle, + children: document.documentType != null + ? [ + const TextSpan(text: '\u30FB'), + WidgetSpan( + child: Material( + color: Colors.transparent, + child: InkWell( + borderRadius: BorderRadius.circular(4), + onTap: onDocumentTypeSelected != null + ? () => onDocumentTypeSelected!(document.documentType) + : null, + child: Text( + context + .watch() + .state + .documentTypes[document.documentType]! + .name, + style: subtitleStyle, + ), + ), + ), + ), + ] + : null, + ), + ); + } +} 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/documents_empty_state.dart b/lib/features/documents/view/widgets/documents_empty_state.dart index 7482265..2937e81 100644 --- a/lib/features/documents/view/widgets/documents_empty_state.dart +++ b/lib/features/documents/view/widgets/documents_empty_state.dart @@ -1,7 +1,7 @@ import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:paperless_api/paperless_api.dart'; -import 'package:paperless_mobile/extensions/flutter_extensions.dart'; +import 'package:paperless_mobile/core/extensions/flutter_extensions.dart'; import 'package:paperless_mobile/features/paged_document_view/cubit/paged_documents_state.dart'; import 'package:paperless_mobile/generated/l10n/app_localizations.dart'; 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 7b671b7..0410e61 100644 --- a/lib/features/documents/view/widgets/items/document_detailed_item.dart +++ b/lib/features/documents/view/widgets/items/document_detailed_item.dart @@ -1,15 +1,17 @@ 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'; import 'package:intl/intl.dart'; import 'package:paperless_api/paperless_api.dart'; -import 'package:paperless_mobile/core/config/hive/hive_config.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/core/repository/label_repository.dart'; -import 'package:paperless_mobile/extensions/flutter_extensions.dart'; +import 'package:paperless_mobile/core/extensions/flutter_extensions.dart'; +import 'package:paperless_mobile/features/documents/view/widgets/date_and_document_type_widget.dart'; import 'package:paperless_mobile/features/documents/view/widgets/document_preview.dart'; import 'package:paperless_mobile/features/documents/view/widgets/items/document_item.dart'; import 'package:paperless_mobile/features/labels/correspondent/view/widgets/correspondent_widget.dart'; @@ -56,6 +58,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,88 +82,59 @@ 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, - children: [ - Text( - DateFormat.yMMMMd(Localizations.localeOf(context).toString()) - .format(document.created), - style: Theme.of(context) - .textTheme - .bodySmall - ?.apply(color: Theme.of(context).hintColor), - ), - if (document.archiveSerialNumber != null) - Row( - children: [ - Text( - '#${document.archiveSerialNumber}', - style: Theme.of(context) - .textTheme - .bodySmall - ?.apply(color: Theme.of(context).hintColor), - ), - ], - ), - ], - ).paddedLTRB(8, 8, 8, 4), + if (paperlessUser.canViewCorrespondents) + CorrespondentWidget( + onSelected: onCorrespondentSelected, + textStyle: Theme.of(context).textTheme.titleSmall?.apply( + color: Theme.of(context).colorScheme.onSurfaceVariant, + ), + correspondent: labels.correspondents[document.correspondent], + ).paddedLTRB(8, 8, 8, 0), Text( - document.title.isEmpty ? '-' : document.title, + document.title.isEmpty ? '(-)' : document.title, style: Theme.of(context).textTheme.titleMedium, maxLines: 2, overflow: TextOverflow.ellipsis, - ).paddedLTRB(8, 0, 8, 4), - if (paperlessUser.canViewCorrespondents) - Row( - children: [ - const Icon( - Icons.person_outline, - size: 16, - ).paddedOnly(right: 4.0), - CorrespondentWidget( - onSelected: onCorrespondentSelected, - textStyle: Theme.of(context).textTheme.titleSmall?.apply( - color: Theme.of(context).colorScheme.onSurfaceVariant, - ), - correspondent: context - .watch() - .state - .correspondents[document.correspondent], + ).paddedLTRB(8, 8, 8, 4), + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Expanded( + child: DateAndDocumentTypeLabelWidget( + document: document, + onDocumentTypeSelected: onDocumentTypeSelected, ), - ], - ).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], + ), + if (document.archiveSerialNumber != null) + Text( + '#${document.archiveSerialNumber}', + style: Theme.of(context) + .textTheme + .bodySmall + ?.apply(color: Theme.of(context).hintColor), ), - ], - ).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, 4, 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..5def307 100644 --- a/lib/features/documents/view/widgets/items/document_grid_item.dart +++ b/lib/features/documents/view/widgets/items/document_grid_item.dart @@ -2,6 +2,7 @@ import 'package:flutter/material.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/extensions/flutter_extensions.dart'; import 'package:paperless_mobile/core/repository/label_repository.dart'; import 'package:paperless_mobile/features/documents/view/widgets/document_preview.dart'; import 'package:paperless_mobile/features/documents/view/widgets/items/document_item.dart'; @@ -29,111 +30,133 @@ class DocumentGridItem extends DocumentItem { @override Widget build(BuildContext context) { var currentUser = context.watch().paperlessUser; - return Padding( - padding: const EdgeInsets.all(8.0), - child: Card( - elevation: 1.0, - color: isSelected - ? Theme.of(context).colorScheme.inversePrimary - : Theme.of(context).cardColor, - child: InkWell( - borderRadius: BorderRadius.circular(12), - onTap: _onTap, - onLongPress: onSelected != null ? () => onSelected!(document) : null, - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - AspectRatio( - aspectRatio: 1, - child: Stack( - children: [ - Positioned.fill( - child: DocumentPreview( - document: document, - borderRadius: 12.0, - enableHero: enableHeroAnimation, - ), - ), - Align( - alignment: Alignment.bottomLeft, - child: SizedBox( - height: 48, - child: NotificationListener( - // Prevents ancestor notification listeners to be notified when this widget scrolls - onNotification: (notification) => true, - child: CustomScrollView( - scrollDirection: Axis.horizontal, - slivers: [ - const SliverToBoxAdapter( - child: SizedBox(width: 8), - ), - if (currentUser.canViewTags) - TagsWidget.sliver( - tags: document.tags - .map((e) => context - .watch() - .state - .tags[e]!) - .toList(), - onTagSelected: onTagSelected, - ), - const SliverToBoxAdapter( - child: SizedBox(width: 8), - ), - ], + return Stack( + children: [ + Card( + elevation: 1.0, + color: isSelected + ? Theme.of(context).colorScheme.inversePrimary + : Theme.of(context).cardColor, + child: InkWell( + borderRadius: BorderRadius.circular(12), + onTap: _onTap, + onLongPress: + onSelected != null ? () => onSelected!(document) : null, + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + ClipRRect( + borderRadius: BorderRadius.circular(12), + child: AspectRatio( + aspectRatio: 1, + child: Stack( + children: [ + Positioned.fill( + child: DocumentPreview( + documentId: document.id, + borderRadius: 12.0, + enableHero: enableHeroAnimation, ), ), - ), + Align( + alignment: Alignment.bottomLeft, + child: SizedBox( + height: kMinInteractiveDimension, + child: NotificationListener( + // Prevents ancestor notification listeners to be notified when this widget scrolls + onNotification: (notification) => true, + child: CustomScrollView( + scrollDirection: Axis.horizontal, + slivers: [ + const SliverToBoxAdapter( + child: SizedBox(width: 8), + ), + if (currentUser.canViewTags) + TagsWidget.sliver( + tags: document.tags + .map((e) => context + .watch() + .state + .tags[e]!) + .toList(), + onTagSelected: onTagSelected, + ), + const SliverToBoxAdapter( + child: SizedBox(width: 8), + ), + ], + ), + ), + ), + ), + ], ), - ], - ), - ), - Expanded( - child: Padding( - padding: const EdgeInsets.all(8.0), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - if (currentUser.canViewCorrespondents) - CorrespondentWidget( - correspondent: context - .watch() - .state - .correspondents[document.correspondent], - onSelected: onCorrespondentSelected, - ), - if (currentUser.canViewDocumentTypes) - DocumentTypeWidget( - documentType: context - .watch() - .state - .documentTypes[document.documentType], - onSelected: onDocumentTypeSelected, - ), - Padding( - padding: const EdgeInsets.only(bottom: 8.0), - child: Text( - document.title.isEmpty ? '-' : document.title, - maxLines: 3, - overflow: TextOverflow.ellipsis, - style: Theme.of(context).textTheme.titleMedium, - ), - ), - const Spacer(), - Text( - DateFormat.yMMMMd( - Localizations.localeOf(context).toString()) - .format(document.created), - style: Theme.of(context).textTheme.bodySmall, - ), - ], ), ), - ), - ], + Expanded( + child: Padding( + padding: const EdgeInsets.all(8.0), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + if (currentUser.canViewCorrespondents) + CorrespondentWidget( + correspondent: context + .watch() + .state + .correspondents[document.correspondent], + onSelected: onCorrespondentSelected, + ), + if (currentUser.canViewDocumentTypes) + DocumentTypeWidget( + documentType: context + .watch() + .state + .documentTypes[document.documentType], + onSelected: onDocumentTypeSelected, + ), + Padding( + padding: const EdgeInsets.only(bottom: 8.0), + child: Text( + document.title.isEmpty ? '-' : document.title, + maxLines: 2, + overflow: TextOverflow.ellipsis, + style: Theme.of(context).textTheme.titleMedium, + ), + ), + const Spacer(), + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text( + DateFormat.yMMMMd( + Localizations.localeOf(context).toString(), + ).format(document.created), + style: Theme.of(context).textTheme.bodySmall, + ), + if (document.archiveSerialNumber != null) + Text( + '#' + document.archiveSerialNumber!.toString(), + style: Theme.of(context) + .textTheme + .bodySmall + ?.copyWith( + color: Theme.of(context) + .colorScheme + .onSurface, + ), + ) + ], + ), + ], + ), + ), + ), + ], + ), ), ), - ), + ], ); } 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..25cc629 100644 --- a/lib/features/documents/view/widgets/items/document_list_item.dart +++ b/lib/features/documents/view/widgets/items/document_list_item.dart @@ -1,7 +1,9 @@ -import 'package:flutter/gestures.dart'; import 'package:flutter/material.dart'; import 'package:intl/intl.dart'; +import 'package:paperless_api/src/models/document_model.dart'; import 'package:paperless_mobile/core/repository/label_repository.dart'; +import 'package:paperless_mobile/core/repository/label_repository_state.dart'; +import 'package:paperless_mobile/features/documents/view/widgets/date_and_document_type_widget.dart'; import 'package:paperless_mobile/features/documents/view/widgets/document_preview.dart'; import 'package:paperless_mobile/features/documents/view/widgets/items/document_item.dart'; import 'package:paperless_mobile/features/labels/correspondent/view/widgets/correspondent_widget.dart'; @@ -31,6 +33,7 @@ class DocumentListItem extends DocumentItem { @override Widget build(BuildContext context) { final labels = context.watch().state; + return ListTile( tileColor: backgroundColor, dense: true, @@ -77,30 +80,9 @@ 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, - ), + child: DateAndDocumentTypeLabelWidget( + document: document, + onDocumentTypeSelected: onDocumentTypeSelected, ), ), isThreeLine: document.tags.isNotEmpty, @@ -108,7 +90,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/documents/view/widgets/placeholder/document_grid_loading_widget.dart b/lib/features/documents/view/widgets/placeholder/document_grid_loading_widget.dart index 5fc7aba..f00a0a9 100644 --- a/lib/features/documents/view/widgets/placeholder/document_grid_loading_widget.dart +++ b/lib/features/documents/view/widgets/placeholder/document_grid_loading_widget.dart @@ -1,6 +1,6 @@ import 'package:flutter/material.dart'; import 'package:paperless_mobile/core/widgets/shimmer_placeholder.dart'; -import 'package:paperless_mobile/extensions/flutter_extensions.dart'; +import 'package:paperless_mobile/core/extensions/flutter_extensions.dart'; import 'package:paperless_mobile/features/documents/view/widgets/placeholder/tags_placeholder.dart'; import 'package:paperless_mobile/features/documents/view/widgets/placeholder/text_placeholder.dart'; diff --git a/lib/features/documents/view/widgets/placeholder/tags_placeholder.dart b/lib/features/documents/view/widgets/placeholder/tags_placeholder.dart index 22528e1..0b9943d 100644 --- a/lib/features/documents/view/widgets/placeholder/tags_placeholder.dart +++ b/lib/features/documents/view/widgets/placeholder/tags_placeholder.dart @@ -1,5 +1,5 @@ import 'package:flutter/material.dart'; -import 'package:paperless_mobile/extensions/flutter_extensions.dart'; +import 'package:paperless_mobile/core/extensions/flutter_extensions.dart'; class TagsPlaceholder extends StatelessWidget { static const _lengths = [90, 70, 130]; diff --git a/lib/features/documents/view/widgets/saved_views/saved_view_chip.dart b/lib/features/documents/view/widgets/saved_views/saved_view_chip.dart index a842a19..dbeb23b 100644 --- a/lib/features/documents/view/widgets/saved_views/saved_view_chip.dart +++ b/lib/features/documents/view/widgets/saved_views/saved_view_chip.dart @@ -2,7 +2,7 @@ import 'dart:math'; import 'package:flutter/material.dart'; import 'package:paperless_api/paperless_api.dart'; -import 'package:paperless_mobile/extensions/flutter_extensions.dart'; +import 'package:paperless_mobile/core/extensions/flutter_extensions.dart'; import 'package:paperless_mobile/routes/typed/branches/saved_views_route.dart'; import 'package:paperless_mobile/routes/typed/shells/authenticated_route.dart'; diff --git a/lib/features/documents/view/widgets/saved_views/saved_views_widget.dart b/lib/features/documents/view/widgets/saved_views/saved_views_widget.dart index 4bb1617..8df1074 100644 --- a/lib/features/documents/view/widgets/saved_views/saved_views_widget.dart +++ b/lib/features/documents/view/widgets/saved_views/saved_views_widget.dart @@ -2,7 +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/widgets/shimmer_placeholder.dart'; -import 'package:paperless_mobile/extensions/flutter_extensions.dart'; +import 'package:paperless_mobile/core/extensions/flutter_extensions.dart'; import 'package:paperless_mobile/features/documents/view/widgets/saved_views/saved_view_chip.dart'; import 'package:paperless_mobile/features/saved_view/cubit/saved_view_cubit.dart'; import 'package:paperless_mobile/generated/l10n/app_localizations.dart'; diff --git a/lib/features/documents/view/widgets/search/sort_field_selection_bottom_sheet.dart b/lib/features/documents/view/widgets/search/sort_field_selection_bottom_sheet.dart index 0746955..0b8d89c 100644 --- a/lib/features/documents/view/widgets/search/sort_field_selection_bottom_sheet.dart +++ b/lib/features/documents/view/widgets/search/sort_field_selection_bottom_sheet.dart @@ -2,7 +2,7 @@ import 'package:flutter/material.dart'; import 'package:font_awesome_flutter/font_awesome_flutter.dart'; import 'package:paperless_api/paperless_api.dart'; import 'package:paperless_mobile/core/translation/sort_field_localization_mapper.dart'; -import 'package:paperless_mobile/extensions/flutter_extensions.dart'; +import 'package:paperless_mobile/core/extensions/flutter_extensions.dart'; import 'package:paperless_mobile/generated/l10n/app_localizations.dart'; class SortFieldSelectionBottomSheet extends StatefulWidget { diff --git a/lib/features/documents/view/widgets/selection/document_selection_sliver_app_bar.dart b/lib/features/documents/view/widgets/selection/document_selection_sliver_app_bar.dart index 6ca80a1..4f5457c 100644 --- a/lib/features/documents/view/widgets/selection/document_selection_sliver_app_bar.dart +++ b/lib/features/documents/view/widgets/selection/document_selection_sliver_app_bar.dart @@ -1,7 +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/extensions/flutter_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/selection/bulk_delete_confirmation_dialog.dart'; import 'package:paperless_mobile/generated/l10n/app_localizations.dart'; diff --git a/lib/features/edit_label/view/label_form.dart b/lib/features/edit_label/view/label_form.dart index 0ffd4c9..2f62bb4 100644 --- a/lib/features/edit_label/view/label_form.dart +++ b/lib/features/edit_label/view/label_form.dart @@ -5,7 +5,7 @@ import 'package:go_router/go_router.dart'; import 'package:paperless_api/paperless_api.dart'; import 'package:paperless_mobile/core/database/tables/local_user_account.dart'; import 'package:paperless_mobile/core/translation/matching_algorithm_localization_mapper.dart'; -import 'package:paperless_mobile/extensions/flutter_extensions.dart'; +import 'package:paperless_mobile/core/extensions/flutter_extensions.dart'; import 'package:paperless_mobile/generated/l10n/app_localizations.dart'; import 'package:paperless_mobile/helpers/message_helpers.dart'; diff --git a/lib/features/home/view/home_shell_widget.dart b/lib/features/home/view/home_shell_widget.dart index 40bc2f0..0c2ef47 100644 --- a/lib/features/home/view/home_shell_widget.dart +++ b/lib/features/home/view/home_shell_widget.dart @@ -2,8 +2,8 @@ import 'package:flutter/material.dart'; import 'package:flutter_cache_manager/flutter_cache_manager.dart'; import 'package:hive_flutter/adapters.dart'; import 'package:paperless_api/paperless_api.dart'; -import 'package:paperless_mobile/core/config/hive/hive_config.dart'; -import 'package:paperless_mobile/core/config/hive/hive_extensions.dart'; +import 'package:paperless_mobile/core/database/hive/hive_config.dart'; +import 'package:paperless_mobile/core/database/hive/hive_extensions.dart'; import 'package:paperless_mobile/core/database/tables/local_user_app_state.dart'; import 'package:paperless_mobile/core/factory/paperless_api_factory.dart'; import 'package:paperless_mobile/core/repository/label_repository.dart'; diff --git a/lib/features/inbox/cubit/inbox_cubit.dart b/lib/features/inbox/cubit/inbox_cubit.dart index 7ab9bb8..68de175 100644 --- a/lib/features/inbox/cubit/inbox_cubit.dart +++ b/lib/features/inbox/cubit/inbox_cubit.dart @@ -1,15 +1,15 @@ import 'dart:async'; -import 'package:flutter/widgets.dart'; import 'package:hydrated_bloc/hydrated_bloc.dart'; import 'package:json_annotation/json_annotation.dart'; import 'package:paperless_api/paperless_api.dart'; +import 'package:paperless_mobile/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/repository/label_repository_state.dart'; import 'package:paperless_mobile/core/service/connectivity_status_service.dart'; -import 'package:paperless_mobile/features/paged_document_view/cubit/paged_documents_state.dart'; import 'package:paperless_mobile/features/paged_document_view/cubit/document_paging_bloc_mixin.dart'; +import 'package:paperless_mobile/features/paged_document_view/cubit/paged_documents_state.dart'; part 'inbox_cubit.g.dart'; part 'inbox_state.dart'; @@ -49,18 +49,12 @@ class InboxCubit extends HydratedCubit final wasInInboxBeforeUpdate = state.documents.map((e) => e.id).contains(document.id); if (!hasInboxTag && wasInInboxBeforeUpdate) { - print( - "INBOX: Removing document: has: $hasInboxTag, had: $wasInInboxBeforeUpdate"); remove(document); emit(state.copyWith(itemsInInboxCount: state.itemsInInboxCount - 1)); } else if (hasInboxTag) { if (wasInInboxBeforeUpdate) { - print( - "INBOX: Replacing document: has: $hasInboxTag, had: $wasInInboxBeforeUpdate"); replace(document); } else { - print( - "INBOX: Adding document: has: $hasInboxTag, had: $wasInInboxBeforeUpdate"); _addDocument(document); emit( state.copyWith(itemsInInboxCount: state.itemsInInboxCount + 1)); @@ -83,11 +77,26 @@ class InboxCubit extends HydratedCubit } Future refreshItemsInInboxCount([bool shouldLoadInbox = true]) async { - debugPrint("Checking for new items in inbox..."); + logger.fi( + "Checking for new documents in inbox...", + className: runtimeType.toString(), + methodName: "refreshItemsInInboxCount", + ); final stats = await _statsApi.getServerStatistics(); if (stats.documentsInInbox != state.itemsInInboxCount && shouldLoadInbox) { + logger.fi( + "New documents found in inbox, reloading.", + className: runtimeType.toString(), + methodName: "refreshItemsInInboxCount", + ); await loadInbox(); + } else { + logger.fi( + "No new documents found in inbox.", + className: runtimeType.toString(), + methodName: "refreshItemsInInboxCount", + ); } emit(state.copyWith(itemsInInboxCount: stats.documentsInInbox)); } @@ -97,7 +106,6 @@ class InboxCubit extends HydratedCubit /// Future loadInbox() async { if (!isClosed) { - debugPrint("Initializing inbox..."); final inboxTags = await _labelRepository.findAllTags().then( (tags) => tags.where((t) => t.isInboxTag).map((t) => t.id!), ); diff --git a/lib/features/inbox/view/pages/inbox_page.dart b/lib/features/inbox/view/pages/inbox_page.dart index e04e83e..8dfefd7 100644 --- a/lib/features/inbox/view/pages/inbox_page.dart +++ b/lib/features/inbox/view/pages/inbox_page.dart @@ -9,8 +9,8 @@ import 'package:paperless_mobile/core/service/connectivity_status_service.dart'; import 'package:paperless_mobile/core/widgets/dialog_utils/dialog_cancel_button.dart'; import 'package:paperless_mobile/core/widgets/dialog_utils/dialog_confirm_button.dart'; import 'package:paperless_mobile/core/widgets/hint_card.dart'; -import 'package:paperless_mobile/extensions/dart_extensions.dart'; -import 'package:paperless_mobile/extensions/flutter_extensions.dart'; +import 'package:paperless_mobile/core/extensions/dart_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'; import 'package:paperless_mobile/features/inbox/cubit/inbox_cubit.dart'; diff --git a/lib/features/inbox/view/widgets/inbox_item.dart b/lib/features/inbox/view/widgets/inbox_item.dart index ae976f1..3b4c569 100644 --- a/lib/features/inbox/view/widgets/inbox_item.dart +++ b/lib/features/inbox/view/widgets/inbox_item.dart @@ -3,9 +3,10 @@ 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/extensions/flutter_extensions.dart'; +import 'package:paperless_mobile/core/extensions/flutter_extensions.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'; import 'package:paperless_mobile/features/documents/view/widgets/placeholder/tags_placeholder.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/labels/correspondent/view/widgets/correspondent_widget.dart b/lib/features/labels/correspondent/view/widgets/correspondent_widget.dart index 8d357c4..4aa4d59 100644 --- a/lib/features/labels/correspondent/view/widgets/correspondent_widget.dart +++ b/lib/features/labels/correspondent/view/widgets/correspondent_widget.dart @@ -21,15 +21,19 @@ class CorrespondentWidget extends StatelessWidget { Widget build(BuildContext context) { return AbsorbPointer( absorbing: !isClickable, - child: GestureDetector( - onTap: () => onSelected?.call(correspondent?.id), - child: Text( - correspondent?.name ?? "-", - maxLines: 1, - overflow: TextOverflow.ellipsis, - style: - (textStyle ?? Theme.of(context).textTheme.bodyMedium)?.copyWith( - color: textColor ?? Theme.of(context).colorScheme.primary, + child: Material( + color: Colors.transparent, + child: InkWell( + borderRadius: BorderRadius.circular(4), + onTap: () => onSelected?.call(correspondent?.id), + child: Text( + correspondent?.name ?? "-", + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: + (textStyle ?? Theme.of(context).textTheme.bodyMedium)?.copyWith( + color: textColor ?? Theme.of(context).colorScheme.primary, + ), ), ), ), diff --git a/lib/features/labels/document_type/view/widgets/document_type_widget.dart b/lib/features/labels/document_type/view/widgets/document_type_widget.dart index 3800766..1567df0 100644 --- a/lib/features/labels/document_type/view/widgets/document_type_widget.dart +++ b/lib/features/labels/document_type/view/widgets/document_type_widget.dart @@ -18,14 +18,18 @@ class DocumentTypeWidget extends StatelessWidget { Widget build(BuildContext context) { return AbsorbPointer( absorbing: !isClickable, - child: GestureDetector( - onTap: () => onSelected?.call(documentType?.id), - child: Text( - documentType?.toString() ?? "-", - style: (textStyle ?? Theme.of(context).textTheme.bodyMedium) - ?.copyWith(color: Theme.of(context).colorScheme.tertiary), - overflow: TextOverflow.ellipsis, - maxLines: 1, + child: Material( + color: Colors.transparent, + child: InkWell( + borderRadius: BorderRadius.circular(4), + onTap: () => onSelected?.call(documentType?.id), + child: Text( + documentType?.toString() ?? "-", + style: (textStyle ?? Theme.of(context).textTheme.bodyMedium) + ?.copyWith(color: Theme.of(context).colorScheme.tertiary), + overflow: TextOverflow.ellipsis, + maxLines: 1, + ), ), ), ); diff --git a/lib/features/labels/tags/view/widgets/fullscreen_tags_form.dart b/lib/features/labels/tags/view/widgets/fullscreen_tags_form.dart index 1fee66e..40d0c1c 100644 --- a/lib/features/labels/tags/view/widgets/fullscreen_tags_form.dart +++ b/lib/features/labels/tags/view/widgets/fullscreen_tags_form.dart @@ -1,7 +1,7 @@ import 'package:collection/collection.dart'; import 'package:flutter/material.dart'; import 'package:paperless_api/paperless_api.dart'; -import 'package:paperless_mobile/extensions/flutter_extensions.dart'; +import 'package:paperless_mobile/core/extensions/flutter_extensions.dart'; import 'package:paperless_mobile/features/edit_label/view/impl/add_tag_page.dart'; import 'package:paperless_mobile/generated/l10n/app_localizations.dart'; import 'package:paperless_mobile/routes/typed/branches/labels_route.dart'; diff --git a/lib/features/labels/tags/view/widgets/tags_form_field.dart b/lib/features/labels/tags/view/widgets/tags_form_field.dart index fc554f7..ba728e0 100644 --- a/lib/features/labels/tags/view/widgets/tags_form_field.dart +++ b/lib/features/labels/tags/view/widgets/tags_form_field.dart @@ -6,7 +6,7 @@ import 'package:flutter_form_builder/flutter_form_builder.dart'; import 'package:paperless_api/paperless_api.dart'; import 'package:paperless_mobile/core/database/tables/local_user_account.dart'; import 'package:paperless_mobile/core/workarounds/colored_chip.dart'; -import 'package:paperless_mobile/extensions/flutter_extensions.dart'; +import 'package:paperless_mobile/core/extensions/flutter_extensions.dart'; import 'package:paperless_mobile/features/labels/tags/view/widgets/fullscreen_tags_form.dart'; import 'package:paperless_mobile/generated/l10n/app_localizations.dart'; diff --git a/lib/features/labels/view/pages/labels_page.dart b/lib/features/labels/view/pages/labels_page.dart index fe2a6a7..1fbcdf3 100644 --- a/lib/features/labels/view/pages/labels_page.dart +++ b/lib/features/labels/view/pages/labels_page.dart @@ -3,10 +3,11 @@ import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:hive_flutter/adapters.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/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/core/delegate/customizable_sliver_persistent_header_delegate.dart'; +import 'package:paperless_mobile/features/logging/data/logger.dart'; import 'package:paperless_mobile/core/widgets/material/colored_tab_bar.dart'; import 'package:paperless_mobile/features/app_drawer/view/app_drawer.dart'; import 'package:paperless_mobile/features/document_search/view/sliver_search_bar.dart'; @@ -212,17 +213,18 @@ class _LabelsPageState extends State ][_currentIndex] .call(); } catch (error, stackTrace) { - debugPrint( - "[LabelsPage] RefreshIndicator.onRefresh " - "${[ - "correspondents", - "document types", - "tags", - "storage paths" - ][_currentIndex]}: " - "An error occurred (${error.toString()})", - ); - debugPrintStack(stackTrace: stackTrace); + logger.fe( + "An error ocurred while reloading " + "${[ + "correspondents", + "document types", + "tags", + "storage paths" + ][_currentIndex]}.", + error: error, + stackTrace: stackTrace, + className: runtimeType.toString(), + methodName: 'onRefresh'); } }, child: TabBarView( diff --git a/lib/features/labels/view/widgets/fullscreen_label_form.dart b/lib/features/labels/view/widgets/fullscreen_label_form.dart index 7c2d0c8..4ca2da4 100644 --- a/lib/features/labels/view/widgets/fullscreen_label_form.dart +++ b/lib/features/labels/view/widgets/fullscreen_label_form.dart @@ -1,7 +1,7 @@ import 'package:flutter/material.dart'; import 'package:flutter/scheduler.dart'; import 'package:paperless_api/paperless_api.dart'; -import 'package:paperless_mobile/extensions/flutter_extensions.dart'; +import 'package:paperless_mobile/core/extensions/flutter_extensions.dart'; import 'package:paperless_mobile/generated/l10n/app_localizations.dart'; class FullscreenLabelForm extends StatefulWidget { diff --git a/lib/features/labels/view/widgets/label_form_field.dart b/lib/features/labels/view/widgets/label_form_field.dart index d660dcb..f7496e6 100644 --- a/lib/features/labels/view/widgets/label_form_field.dart +++ b/lib/features/labels/view/widgets/label_form_field.dart @@ -4,7 +4,7 @@ import 'package:flutter/material.dart'; import 'package:flutter_form_builder/flutter_form_builder.dart'; import 'package:paperless_api/paperless_api.dart'; import 'package:paperless_mobile/core/workarounds/colored_chip.dart'; -import 'package:paperless_mobile/extensions/flutter_extensions.dart'; +import 'package:paperless_mobile/core/extensions/flutter_extensions.dart'; import 'package:paperless_mobile/features/labels/view/widgets/fullscreen_label_form.dart'; import 'package:paperless_mobile/generated/l10n/app_localizations.dart'; diff --git a/lib/features/labels/view/widgets/label_tab_view.dart b/lib/features/labels/view/widgets/label_tab_view.dart index 9d6ce8c..01b3c88 100644 --- a/lib/features/labels/view/widgets/label_tab_view.dart +++ b/lib/features/labels/view/widgets/label_tab_view.dart @@ -5,7 +5,7 @@ import 'package:paperless_mobile/core/bloc/connectivity_cubit.dart'; import 'package:paperless_mobile/core/translation/matching_algorithm_localization_mapper.dart'; import 'package:paperless_mobile/core/widgets/offline_widget.dart'; import 'package:paperless_mobile/features/labels/view/widgets/label_item.dart'; -import 'package:paperless_mobile/extensions/flutter_extensions.dart'; +import 'package:paperless_mobile/core/extensions/flutter_extensions.dart'; class LabelTabView extends StatelessWidget { final Map labels; diff --git a/lib/features/landing/view/landing_page.dart b/lib/features/landing/view/landing_page.dart index 78c7deb..4df3007 100644 --- a/lib/features/landing/view/landing_page.dart +++ b/lib/features/landing/view/landing_page.dart @@ -3,7 +3,7 @@ import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:paperless_api/paperless_api.dart'; import 'package:paperless_mobile/constants.dart'; import 'package:paperless_mobile/core/database/tables/local_user_account.dart'; -import 'package:paperless_mobile/extensions/flutter_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'; import 'package:paperless_mobile/features/landing/view/widgets/expansion_card.dart'; @@ -18,12 +18,6 @@ import 'package:paperless_mobile/routes/typed/shells/authenticated_route.dart'; import 'package:paperless_mobile/routes/typed/top_level/changelog_route.dart'; import 'package:shared_preferences/shared_preferences.dart'; -class Changelog { - final int buildNumber; - final String? changelog; - Changelog(this.buildNumber, this.changelog); -} - class LandingPage extends StatefulWidget { const LandingPage({super.key}); 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/logging/cubit/app_logs_cubit.dart b/lib/features/logging/cubit/app_logs_cubit.dart new file mode 100644 index 0000000..6ec2eb1 --- /dev/null +++ b/lib/features/logging/cubit/app_logs_cubit.dart @@ -0,0 +1,119 @@ +import 'dart:async'; +import 'dart:io'; + +import 'package:bloc/bloc.dart'; +import 'package:collection/collection.dart'; +import 'package:file_picker/file_picker.dart'; +import 'package:flutter/services.dart'; +import 'package:intl/intl.dart'; +import 'package:paperless_mobile/features/logging/models/parsed_log_message.dart'; +import 'package:paperless_mobile/core/service/file_service.dart'; +import 'package:paperless_mobile/features/notifications/services/local_notification_service.dart'; +import 'package:path/path.dart' as p; +import 'package:permission_handler/permission_handler.dart'; +import 'package:rxdart/rxdart.dart'; +part 'app_logs_state.dart'; + +final _fileNameFormat = DateFormat("yyyy-MM-dd"); + +class AppLogsCubit extends Cubit { + StreamSubscription? _fileChangesSubscription; + final LocalNotificationService _localNotificationService; + AppLogsCubit( + DateTime date, + this._localNotificationService, + ) : super(AppLogsStateInitial(date: date)); + + Future loadLogs(DateTime date) async { + if (date == state.date) { + return; + } + _fileChangesSubscription?.cancel(); + emit(AppLogsStateLoading(date: date)); + final logDir = FileService.instance.logDirectory; + final availableLogs = (await logDir + .list() + .whereType() + .where((event) => event.path.endsWith('.log')) + .map((e) => + _fileNameFormat.parse(p.basenameWithoutExtension(e.path))) + .toList()) + .sorted(); + final logFile = _getLogfile(date); + if (!await logFile.exists()) { + emit(AppLogsStateLoaded( + date: date, + logs: [], + availableLogs: availableLogs, + )); + } + try { + _updateLogsFromFile(logFile, date, availableLogs); + _fileChangesSubscription = logFile.watch().listen((event) async { + if (!isClosed) { + _updateLogsFromFile(logFile, date, availableLogs); + } + }); + } catch (e) { + emit(AppLogsStateError( + error: e, + date: date, + )); + } + } + + void _updateLogsFromFile( + File file, DateTime date, List availableLogs) async { + final logs = await file.readAsLines(); + final parsedLogs = ParsedLogMessage.parse(logs).reversed.toList(); + emit(AppLogsStateLoaded( + date: date, + logs: parsedLogs, + availableLogs: availableLogs, + )); + } + + Future clearLogs(DateTime date) async { + final logFile = _getLogfile(date); + await logFile.writeAsString(''); + await loadLogs(date); + } + + Future copyToClipboard(DateTime date) async { + final file = _getLogfile(date); + if (!await file.exists()) { + return; + } + final content = await file.readAsString(); + Clipboard.setData(ClipboardData(text: content)); + } + + Future saveLogs(DateTime date, String locale) async { + var formattedDate = _fileNameFormat.format(date); + final filename = 'paperless_mobile_logs_$formattedDate.log'; + // final parentDir = await FilePicker.platform.getDirectoryPath( + // dialogTitle: "Save log from ${DateFormat.yMd(locale).format(date)}", + // initialDirectory: Platform.isAndroid + // ? FileService.instance.downloadsDirectory.path + // : null, + // ); + // if (parentDir == null) { + // return; + // } + final logFile = _getLogfile(date); + final parentDir = FileService.instance.downloadsDirectory; + final downloadedFile = await logFile.copy(p.join(parentDir.path, filename)); + _localNotificationService.notifyFileDownload(filePath: downloadedFile.path); + } + + File _getLogfile(DateTime date) { + return File(p.join(FileService.instance.logDirectory.path, + '${_fileNameFormat.format(date)}.log')); + } + + @override + Future close() async { + await _fileChangesSubscription?.cancel(); + return super.close(); + } +} diff --git a/lib/features/logging/cubit/app_logs_state.dart b/lib/features/logging/cubit/app_logs_state.dart new file mode 100644 index 0000000..f8b1966 --- /dev/null +++ b/lib/features/logging/cubit/app_logs_state.dart @@ -0,0 +1,33 @@ +part of 'app_logs_cubit.dart'; + +sealed class AppLogsState { + final DateTime date; + const AppLogsState({required this.date}); +} + +class AppLogsStateInitial extends AppLogsState { + const AppLogsStateInitial({required super.date}); +} + +class AppLogsStateLoading extends AppLogsState { + const AppLogsStateLoading({required super.date}); +} + +class AppLogsStateLoaded extends AppLogsState { + const AppLogsStateLoaded({ + required super.date, + required this.logs, + required this.availableLogs, + }); + final List availableLogs; + final List logs; +} + +class AppLogsStateError extends AppLogsState { + const AppLogsStateError({ + required this.error, + required super.date, + }); + + final Object error; +} diff --git a/lib/features/logging/data/formatted_printer.dart b/lib/features/logging/data/formatted_printer.dart new file mode 100644 index 0000000..c2faec9 --- /dev/null +++ b/lib/features/logging/data/formatted_printer.dart @@ -0,0 +1,45 @@ +import 'dart:convert'; + +import 'package:collection/collection.dart'; +import 'package:flutter/material.dart'; +import 'package:intl/intl.dart'; +import 'package:logger/logger.dart'; +import 'package:paperless_mobile/features/logging/models/formatted_log_message.dart'; + +class FormattedPrinter extends LogPrinter { + static final _timestampFormat = DateFormat("yyyy-MM-dd HH:mm:ss.SSS"); + static const _mulitlineObjectEncoder = JsonEncoder.withIndent(null); + + @override + List log(LogEvent event) { + final unformattedMessage = event.message; + final formattedMessage = switch (unformattedMessage) { + FormattedLogMessage m => m.format(), + Iterable i => _mulitlineObjectEncoder + .convert(i) + .padLeft(FormattedLogMessage.maxLength), + Map m => _mulitlineObjectEncoder + .convert(m) + .padLeft(FormattedLogMessage.maxLength), + _ => unformattedMessage.toString().padLeft(FormattedLogMessage.maxLength), + }; + final formattedLevel = event.level.name + .toUpperCase() + .padRight(Level.values.map((e) => e.name.length).max); + final formattedTimestamp = _timestampFormat.format(event.time); + + return [ + '$formattedTimestamp\t$formattedLevel --- $formattedMessage', + if (event.error != null) ...[ + "---BEGIN ERROR---", + event.error.toString(), + "---END ERROR---", + ], + if (event.stackTrace != null) ...[ + "---BEGIN STACKTRACE---", + event.stackTrace.toString(), + "---END STACKTRACE---" + ], + ]; + } +} diff --git a/lib/features/logging/data/logger.dart b/lib/features/logging/data/logger.dart new file mode 100644 index 0000000..d254237 --- /dev/null +++ b/lib/features/logging/data/logger.dart @@ -0,0 +1,116 @@ +import 'package:logger/logger.dart'; +import 'package:paperless_mobile/features/logging/models/formatted_log_message.dart'; + +late Logger logger; + +extension FormattedLoggerExtension on Logger { + void ft( + dynamic message, { + String className = '', + String methodName = '', + DateTime? time, + Object? error, + StackTrace? stackTrace, + }) { + final formattedMessage = FormattedLogMessage( + message, + className: className, + methodName: methodName, + ); + log( + Level.trace, + formattedMessage, + time: time, + error: error, + stackTrace: stackTrace, + ); + } + + void fw( + dynamic message, { + String className = '', + String methodName = '', + DateTime? time, + Object? error, + StackTrace? stackTrace, + }) { + final formattedMessage = FormattedLogMessage( + message, + className: className, + methodName: methodName, + ); + log( + Level.warning, + formattedMessage, + time: time, + error: error, + stackTrace: stackTrace, + ); + } + + void fd( + dynamic message, { + String className = '', + String methodName = '', + DateTime? time, + Object? error, + StackTrace? stackTrace, + }) { + final formattedMessage = FormattedLogMessage( + message, + className: className, + methodName: methodName, + ); + log( + Level.debug, + formattedMessage, + time: time, + error: error, + stackTrace: stackTrace, + ); + } + + void fi( + dynamic message, { + String className = '', + String methodName = '', + DateTime? time, + Object? error, + StackTrace? stackTrace, + }) { + final formattedMessage = FormattedLogMessage( + message, + className: className, + methodName: methodName, + ); + log( + Level.info, + formattedMessage, + time: time, + error: error, + stackTrace: stackTrace, + ); + } + + void fe( + dynamic message, { + String className = '', + String methodName = '', + DateTime? time, + Object? error, + StackTrace? stackTrace, + }) { + final formattedMessage = FormattedLogMessage( + message, + className: className, + methodName: methodName, + ); + log( + Level.error, + formattedMessage, + time: time, + error: error, + stackTrace: stackTrace, + ); + } +} diff --git a/lib/features/logging/data/mirrored_file_output.dart b/lib/features/logging/data/mirrored_file_output.dart new file mode 100644 index 0000000..01cef24 --- /dev/null +++ b/lib/features/logging/data/mirrored_file_output.dart @@ -0,0 +1,52 @@ +import 'dart:async'; +import 'dart:io'; + +import 'package:collection/collection.dart'; +import 'package:flutter/material.dart'; +import 'package:intl/intl.dart'; +import 'package:logger/logger.dart'; +import 'package:paperless_mobile/core/service/file_service.dart'; +import 'package:path/path.dart' as p; +import 'package:synchronized/synchronized.dart'; + +typedef f = FileOutput; + +class MirroredFileOutput extends LogOutput { + var lock = Lock(); + MirroredFileOutput(); + + late final File file; + + @override + Future init() async { + final today = DateFormat("yyyy-MM-dd").format(DateTime.now()); + final logDir = FileService.instance.logDirectory; + file = File(p.join(logDir.path, '$today.log')); + debugPrint("Logging files to ${file.path}."); + try { + final oldLogs = await FileService.instance.getAllFiles(logDir); + if (oldLogs.length > 10) { + oldLogs + .sortedBy((file) => file.lastModifiedSync()) + .reversed + .skip(10) + .forEach((log) => log.delete()); + } + } catch (e) { + debugPrint("Failed to delete old logs..."); + } + } + + @override + void output(OutputEvent event) async { + await lock.synchronized(() async { + for (var line in event.lines) { + debugPrint(line); + await file.writeAsString( + "$line${Platform.lineTerminator}", + mode: FileMode.append, + ); + } + }); + } +} diff --git a/lib/features/logging/models/formatted_log_message.dart b/lib/features/logging/models/formatted_log_message.dart new file mode 100644 index 0000000..94b13ce --- /dev/null +++ b/lib/features/logging/models/formatted_log_message.dart @@ -0,0 +1,19 @@ +/// Class passed to the printer to be formatted and printed. +class FormattedLogMessage { + static const maxLength = 55; + final String message; + final String methodName; + final String className; + + FormattedLogMessage( + this.message, { + required this.methodName, + required this.className, + }); + + String format() { + final formattedClassName = className.padLeft(25); + final formattedMethodName = methodName.padRight(25); + return '[$formattedClassName] - $formattedMethodName: $message'; + } +} diff --git a/lib/features/logging/models/parsed_log_message.dart b/lib/features/logging/models/parsed_log_message.dart new file mode 100644 index 0000000..d63a591 --- /dev/null +++ b/lib/features/logging/models/parsed_log_message.dart @@ -0,0 +1,149 @@ +import 'dart:io'; + +import 'package:logger/logger.dart'; + +final _newLine = Platform.lineTerminator; + +sealed class ParsedLogMessage { + static List parse(List logs) { + List messages = []; + int offset = 0; + while (offset < logs.length) { + final currentLine = logs[offset]; + if (ParsedFormattedLogMessage.canConsumeFirstLine(currentLine)) { + final (consumedLines, result) = + ParsedFormattedLogMessage.consume(logs.sublist(offset)); + messages.add(result); + offset += consumedLines; + } else { + messages.add(UnformattedLogMessage(currentLine)); + offset++; + } + } + return messages; + } +} + +class ParsedErrorLogMessage { + static final RegExp _errorBeginPattern = RegExp(r"---BEGIN ERROR---\s*"); + static final RegExp _errorEndPattern = RegExp(r"---END ERROR---\s*"); + static final RegExp _stackTraceBeginPattern = + RegExp(r"---BEGIN STACKTRACE---\s*"); + static final RegExp _stackTraceEndPattern = + RegExp(r"---END STACKTRACE---\s*"); + final String error; + final String? stackTrace; + ParsedErrorLogMessage({ + required this.error, + this.stackTrace, + }); + static bool canConsumeFirstLine(String line) => + _errorBeginPattern.hasMatch(line); + + static (int consumedLines, ParsedErrorLogMessage? result) consume( + List log) { + assert(log.isNotEmpty && canConsumeFirstLine(log.first)); + String errorText = ""; + int currentLine = + 1; // Skip first because we know that the first line is ---BEGIN ERROR--- + + while (!_errorEndPattern.hasMatch(log[currentLine])) { + errorText += log[currentLine] + _newLine; + currentLine++; + } + currentLine++; + final hasStackTrace = _stackTraceBeginPattern.hasMatch(log[currentLine]); + String? stackTrace; + if (hasStackTrace) { + currentLine++; + String stackTraceText = ''; + + while (!_stackTraceEndPattern.hasMatch(log[currentLine])) { + stackTraceText += log[currentLine] + _newLine; + currentLine++; + } + stackTrace = stackTraceText; + } + return ( + currentLine + 1, + ParsedErrorLogMessage(error: errorText, stackTrace: stackTrace) + ); + } +} + +class UnformattedLogMessage extends ParsedLogMessage { + final String message; + + UnformattedLogMessage(this.message); +} + +class ParsedFormattedLogMessage extends ParsedLogMessage { + static final RegExp pattern = RegExp( + r'(?\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}\.\d{3})\s*(?[A-Z]*)' + r'\s*---\s*(?:\[\s*(?.*)\]\s*-\s*(?.*)\s*)?:\s*(?.+)', + ); + + final Level level; + final String message; + final String? className; + final String? methodName; + final DateTime timestamp; + + final ParsedErrorLogMessage? error; + + ParsedFormattedLogMessage({ + required this.level, + required this.message, + this.className, + this.methodName, + required this.timestamp, + this.error, + }); + + static bool canConsumeFirstLine(String line) => pattern.hasMatch(line); + + static (int consumedLines, ParsedFormattedLogMessage result) consume( + List log) { + assert(log.isNotEmpty && canConsumeFirstLine(log.first)); + + final match = pattern.firstMatch(log.first)!; + final result = ParsedFormattedLogMessage( + level: Level.values.byName(match.namedGroup('level')!.toLowerCase()), + message: match.namedGroup('message')!, + className: match.namedGroup('className'), + methodName: match.namedGroup('methodName'), + timestamp: DateTime.parse(match.namedGroup('timestamp')!), + ); + final updatedLog = log.sublist(1); + if (updatedLog.isEmpty) { + return (1, result); + } + if (ParsedErrorLogMessage.canConsumeFirstLine(updatedLog.first)) { + final (consumedLines, parsedError) = + ParsedErrorLogMessage.consume(updatedLog); + return ( + consumedLines + 1, + result.copyWith(error: parsedError), + ); + } + return (1, result); + } + + ParsedFormattedLogMessage copyWith({ + Level? level, + String? message, + String? className, + String? methodName, + DateTime? timestamp, + ParsedErrorLogMessage? error, + }) { + return ParsedFormattedLogMessage( + level: level ?? this.level, + message: message ?? this.message, + className: className ?? this.className, + methodName: methodName ?? this.methodName, + timestamp: timestamp ?? this.timestamp, + error: error ?? this.error, + ); + } +} diff --git a/lib/features/logging/utils/redaction_utils.dart b/lib/features/logging/utils/redaction_utils.dart new file mode 100644 index 0000000..724e6fa --- /dev/null +++ b/lib/features/logging/utils/redaction_utils.dart @@ -0,0 +1,22 @@ +(String username, String obscuredUrl) splitRedactUserId(String userId) { + final parts = userId.split('@'); + if (parts.length != 2) { + return ('unknown', 'unknown'); + } + + final username = parts.first; + final serverUrl = parts.last; + final uri = Uri.parse(serverUrl); + final hostLen = uri.host.length; + final obscuredUrl = uri.scheme + + "://" + + uri.host.substring(0, 2) + + List.filled(hostLen - 4, '*').join() + + uri.host.substring(uri.host.length - 2, uri.host.length); + return (username, obscuredUrl); +} + +String redactUserId(String userId) { + final (username, obscuredUrl) = splitRedactUserId(userId); + return '$username@$obscuredUrl'; +} diff --git a/lib/features/logging/view/app_logs_page.dart b/lib/features/logging/view/app_logs_page.dart new file mode 100644 index 0000000..19e0d16 --- /dev/null +++ b/lib/features/logging/view/app_logs_page.dart @@ -0,0 +1,284 @@ +import 'package:flutter/cupertino.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:intl/intl.dart'; +import 'package:logger/logger.dart'; +import 'package:paperless_mobile/features/logging/cubit/app_logs_cubit.dart'; +import 'package:paperless_mobile/features/logging/models/parsed_log_message.dart'; +import 'package:paperless_mobile/core/extensions/dart_extensions.dart'; +import 'package:paperless_mobile/core/extensions/flutter_extensions.dart'; +import 'package:paperless_mobile/generated/l10n/app_localizations.dart'; + +class AppLogsPage extends StatefulWidget { + const AppLogsPage({super.key}); + + @override + State createState() => _AppLogsPageState(); +} + +class _AppLogsPageState extends State { + final ScrollController _scrollController = ScrollController(); + + bool autoScroll = true; + + @override + Widget build(BuildContext context) { + final locale = Localizations.localeOf(context).toString(); + final theme = Theme.of(context); + return BlocBuilder( + builder: (context, state) { + final formattedDate = DateFormat.yMMMd(locale).format(state.date); + return Scaffold( + bottomNavigationBar: BottomAppBar( + child: Row( + mainAxisAlignment: MainAxisAlignment.start, + children: switch (state) { + AppLogsStateInitial() => [], + AppLogsStateLoading() => [], + AppLogsStateLoaded() => [ + IconButton( + tooltip: S.of(context)!.copyToClipboard, + onPressed: () { + context + .read() + .copyToClipboard(state.date); + }, + icon: const Icon(Icons.copy), + ).padded(), + IconButton( + tooltip: S.of(context)!.saveLogsToFile, + onPressed: () { + context + .read() + .saveLogs(state.date, locale); + }, + icon: const Icon(Icons.download), + ).padded(), + IconButton( + tooltip: S.of(context)!.clearLogs(formattedDate), + onPressed: () { + context.read().clearLogs(state.date); + }, + icon: Icon( + Icons.delete_sweep, + color: Theme.of(context).colorScheme.error, + ), + ).padded(), + ], + _ => [], + }, + ), + ), + appBar: AppBar( + title: Text(S + .of(context)! + .appLogs(formattedDate)), //TODO: CHange to App-Logs in german + actions: [ + if (state is AppLogsStateLoaded) + IconButton( + tooltip: MaterialLocalizations.of(context).datePickerHelpText, + onPressed: () async { + final selectedDate = await showDatePicker( + context: context, + initialDate: state.date, + firstDate: state.availableLogs.first, + lastDate: state.availableLogs.last, + selectableDayPredicate: (day) => state.availableLogs + .any((date) => day.isOnSameDayAs(date)), + initialEntryMode: DatePickerEntryMode.calendarOnly, + ); + if (selectedDate != null) { + context.read().loadLogs(selectedDate); + } + }, + icon: const Icon(Icons.calendar_today), + ).padded(), + ], + ), + body: switch (state) { + AppLogsStateLoaded( + logs: var logs, + ) => + Builder( + builder: (context) { + if (state.logs.isEmpty) { + return Center( + child: Text(S.of(context)!.noLogsFoundOn(formattedDate)), + ); + } + return ListView.builder( + reverse: true, + controller: _scrollController, + itemBuilder: (context, index) { + if (index == 0) { + return Center( + child: Text(S.of(context)!.logfileBottomReached, + style: theme.textTheme.bodySmall?.copyWith( + color: theme.disabledColor, + )), + ).padded(24); + } + final messages = state.logs; + final logMessage = messages[index - 1]; + final altColor = CupertinoDynamicColor.withBrightness( + color: Colors.grey.shade200, + darkColor: Colors.grey.shade800, + ).resolveFrom(context); + return ParsedLogMessageTile( + message: logMessage, + backgroundColor: (index % 2 == 0) + ? theme.colorScheme.background + : altColor, + ); + }, + itemCount: logs.length + 1, + ); + }, + ), + AppLogsStateError() => Center( + child: + Text(S.of(context)!.couldNotLoadLogfileFrom(formattedDate)), + ), + _ => _buildLoadingLogs(state.date) + }, + ); + }, + ); + } + + Widget _buildLoadingLogs(DateTime date) { + final formattedDate = + DateFormat.yMd(Localizations.localeOf(context).toString()).format(date); + return Center( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + const CircularProgressIndicator(), + Text(S.of(context)!.loadingLogsFrom(formattedDate)), + ], + ), + ); + } +} + +class ParsedLogMessageTile extends StatelessWidget { + final ParsedLogMessage message; + final Color backgroundColor; + + const ParsedLogMessageTile({ + super.key, + required this.message, + required this.backgroundColor, + }); + + @override + Widget build(BuildContext context) { + return switch (message) { + ParsedFormattedLogMessage m => FormattedLogMessageWidget( + message: m, + backgroundColor: backgroundColor, + ), + UnformattedLogMessage(message: var m) => Text(m), + }; + } +} + +class FormattedLogMessageWidget extends StatelessWidget { + final ParsedFormattedLogMessage message; + final Color backgroundColor; + const FormattedLogMessageWidget( + {super.key, required this.message, required this.backgroundColor}); + static final _timeFormat = DateFormat("HH:mm:ss.SSS"); + @override + Widget build(BuildContext context) { + final c = Theme.of(context).colorScheme; + + final icon = switch (message.level) { + Level.trace => Icons.troubleshoot, + Level.debug => Icons.bug_report, + Level.info => Icons.info_outline, + Level.warning => Icons.warning, + Level.error => Icons.error, + Level.fatal => Icons.error_outline, + _ => null, + }; + final color = switch (message.level) { + Level.trace => c.onBackground.withOpacity(0.75), + Level.warning => Colors.yellow.shade600, + Level.error => Colors.red, + Level.fatal => Colors.red.shade900, + Level.info => Colors.blue, + _ => c.onBackground, + }; + + final logStyle = Theme.of(context).textTheme.bodyMedium?.copyWith( + fontFamily: 'monospace', + fontSize: 12, + ); + final formattedMethodName = + message.methodName != null ? '${message.methodName!.trim()}()' : ''; + final source = switch (message.className) { + '' || null => formattedMethodName, + String className => '$className.$formattedMethodName', + }; + return Material( + color: backgroundColor, + child: ExpansionTile( + leading: Text( + _timeFormat.format(message.timestamp), + style: logStyle?.copyWith(color: color), + ), + title: Text( + message.message, + style: logStyle?.copyWith(color: color), + ), + trailing: Icon( + icon, + color: color, + ), + expandedCrossAxisAlignment: CrossAxisAlignment.start, + childrenPadding: const EdgeInsets.fromLTRB(16, 8, 16, 8), + expandedAlignment: Alignment.topLeft, + children: source.isNotEmpty + ? [ + Row( + children: [ + const Icon(Icons.arrow_right), + Flexible( + child: Text( + 'In $source', + style: logStyle?.copyWith(fontSize: 14), + ), + ), + ], + ), + ..._buildErrorWidgets(context), + ] + : _buildErrorWidgets(context), + ), + ); + } + + List _buildErrorWidgets(BuildContext context) { + if (message.error != null) { + return [ + Divider(), + Text( + message.error!.error, + style: TextStyle(color: Colors.red), + ).padded(), + if (message.error?.stackTrace != null) ...[ + Text( + message.error!.stackTrace!, + style: Theme.of(context).textTheme.bodySmall?.copyWith( + fontFamily: 'monospace', + fontSize: 10, + ), + ).paddedOnly(left: 8), + ], + ]; + } else { + return []; + } + } +} diff --git a/lib/features/login/cubit/authentication_cubit.dart b/lib/features/login/cubit/authentication_cubit.dart index d4ba6e2..6f3993d 100644 --- a/lib/features/login/cubit/authentication_cubit.dart +++ b/lib/features/login/cubit/authentication_cubit.dart @@ -4,8 +4,8 @@ import 'package:flutter/widgets.dart'; import 'package:hive_flutter/adapters.dart'; import 'package:hydrated_bloc/hydrated_bloc.dart'; import 'package:paperless_api/paperless_api.dart'; -import 'package:paperless_mobile/core/config/hive/hive_config.dart'; -import 'package:paperless_mobile/core/config/hive/hive_extensions.dart'; +import 'package:paperless_mobile/core/database/hive/hive_config.dart'; +import 'package:paperless_mobile/core/database/hive/hive_extensions.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/core/database/tables/local_user_app_state.dart'; @@ -13,6 +13,8 @@ import 'package:paperless_mobile/core/database/tables/local_user_settings.dart'; import 'package:paperless_mobile/core/database/tables/user_credentials.dart'; import 'package:paperless_mobile/core/factory/paperless_api_factory.dart'; import 'package:paperless_mobile/core/interceptor/language_header.interceptor.dart'; +import 'package:paperless_mobile/features/logging/data/logger.dart'; +import 'package:paperless_mobile/features/logging/utils/redaction_utils.dart'; import 'package:paperless_mobile/core/model/info_message_exception.dart'; import 'package:paperless_mobile/core/security/session_manager.dart'; import 'package:paperless_mobile/core/service/connectivity_status_service.dart'; @@ -55,9 +57,12 @@ class AuthenticationCubit extends Cubit { } emit(const AuthenticatingState(AuthenticatingStage.authenticating)); final localUserId = "${credentials.username}@$serverUrl"; - _debugPrintMessage( - "login", - "Trying to login $localUserId...", + final redactedId = redactUserId(localUserId); + + logger.fd( + "Trying to log in $redactedId...", + className: runtimeType.toString(), + methodName: 'login', ); try { await _addUser( @@ -97,35 +102,35 @@ class AuthenticationCubit extends Cubit { await globalSettings.save(); emit(AuthenticatedState(localUserId: localUserId)); - _debugPrintMessage( - "login", - "User successfully logged in.", + logger.fd( + 'User $redactedId successfully logged in.', + className: runtimeType.toString(), + methodName: 'login', ); } /// Switches to another account if it exists. Future switchAccount(String localUserId) async { emit(const SwitchingAccountsState()); - _debugPrintMessage( - "switchAccount", - "Trying to switch to user $localUserId...", + final redactedId = redactUserId(localUserId); + logger.fd( + 'Trying to switch to user $redactedId...', + className: runtimeType.toString(), + methodName: 'switchAccount', ); final globalSettings = Hive.box(HiveBoxes.globalSettings).getValue()!; - // if (globalSettings.loggedInUserId == localUserId) { - // _debugPrintMessage( - // "switchAccount", - // "User $localUserId is already logged in.", - // ); - // emit(AuthenticatedState(localUserId: localUserId)); - // return; - // } final userAccountBox = Hive.localUserAccountBox; if (!userAccountBox.containsKey(localUserId)) { - debugPrint("User $localUserId not yet registered."); + logger.fw( + 'User $redactedId not yet registered. ' + 'This should never be the case!', + className: runtimeType.toString(), + methodName: 'switchAccount', + ); return; } @@ -135,9 +140,10 @@ class AuthenticationCubit extends Cubit { final authenticated = await _localAuthService .authenticateLocalUser("Authenticate to switch your account."); if (!authenticated) { - _debugPrintMessage( - "switchAccount", + logger.fw( "User could not be authenticated.", + className: runtimeType.toString(), + methodName: 'switchAccount', ); emit(VerifyIdentityState(userId: localUserId)); return; @@ -151,7 +157,11 @@ class AuthenticationCubit extends Cubit { HiveBoxes.localUserCredentials, (credentialsBox) async { if (!credentialsBox.containsKey(localUserId)) { await credentialsBox.close(); - debugPrint("Invalid authentication for $localUserId"); + logger.fw( + "Invalid authentication for $redactedId.", + className: runtimeType.toString(), + methodName: 'switchAccount', + ); return; } final credentials = credentialsBox.get(localUserId); @@ -188,6 +198,12 @@ class AuthenticationCubit extends Cubit { }) async { assert(credentials.password != null && credentials.username != null); final localUserId = "${credentials.username}@$serverUrl"; + final redactedId = redactUserId(localUserId); + logger.fd( + "Adding account $redactedId...", + className: runtimeType.toString(), + methodName: 'switchAccount', + ); final sessionManager = SessionManager([ LanguageHeaderInterceptor(locale), @@ -204,9 +220,16 @@ class AuthenticationCubit extends Cubit { } Future removeAccount(String userId) async { + final redactedId = redactUserId(userId); + logger.fd( + "Trying to remove account $redactedId...", + className: runtimeType.toString(), + methodName: 'removeAccount', + ); final userAccountBox = Hive.localUserAccountBox; final userAppStateBox = Hive.localUserAppStateBox; - await FileService.clearUserData(userId: userId); + + await FileService.instance.clearUserData(userId: userId); await userAccountBox.delete(userId); await userAppStateBox.delete(userId); await withEncryptedBox( @@ -220,18 +243,20 @@ class AuthenticationCubit extends Cubit { /// Future restoreSession([String? userId]) async { emit(const RestoringSessionState()); - _debugPrintMessage( - "restoreSessionState", + logger.fd( "Trying to restore previous session...", + className: runtimeType.toString(), + methodName: 'restoreSession', ); final globalSettings = Hive.box(HiveBoxes.globalSettings).getValue()!; final restoreSessionForUser = userId ?? globalSettings.loggedInUserId; // final localUserId = globalSettings.loggedInUserId; if (restoreSessionForUser == null) { - _debugPrintMessage( - "restoreSessionState", + logger.fd( "There is nothing to restore.", + className: runtimeType.toString(), + methodName: 'restoreSession', ); final otherAccountsExist = Hive.localUserAccountBox.isNotEmpty; // If there is nothing to restore, we can quit here. @@ -243,14 +268,11 @@ class AuthenticationCubit extends Cubit { final localUserAccountBox = Hive.box(HiveBoxes.localUserAccount); final localUserAccount = localUserAccountBox.get(restoreSessionForUser)!; - _debugPrintMessage( - "restoreSessionState", - "Checking if biometric authentication is required...", - ); if (localUserAccount.settings.isBiometricAuthenticationEnabled) { - _debugPrintMessage( - "restoreSessionState", - "Biometric authentication required, waiting for user to authenticate...", + logger.fd( + "Verifying user identity...", + className: runtimeType.toString(), + methodName: 'restoreSession', ); final authenticationMesage = (await S.delegate.load(Locale(globalSettings.preferredLocaleSubtag))) @@ -258,26 +280,24 @@ class AuthenticationCubit extends Cubit { final localAuthSuccess = await _localAuthService.authenticateLocalUser(authenticationMesage); if (!localAuthSuccess) { - emit(VerifyIdentityState(userId: restoreSessionForUser)); - _debugPrintMessage( - "restoreSessionState", - "User could not be authenticated.", + logger.fw( + "Identity could not be verified.", + className: runtimeType.toString(), + methodName: 'restoreSession', ); + emit(VerifyIdentityState(userId: restoreSessionForUser)); return; } - _debugPrintMessage( - "restoreSessionState", - "User successfully autheticated.", - ); - } else { - _debugPrintMessage( - "restoreSessionState", - "Biometric authentication not configured, skipping.", + logger.fd( + "Identity successfully verified.", + className: runtimeType.toString(), + methodName: 'restoreSession', ); } - _debugPrintMessage( - "restoreSessionState", - "Trying to retrieve authentication credentials...", + logger.fd( + "Reading encrypted credentials...", + className: runtimeType.toString(), + methodName: 'restoreSession', ); final authentication = await withEncryptedBox( @@ -286,23 +306,25 @@ class AuthenticationCubit extends Cubit { }); if (authentication == null) { - _debugPrintMessage( - "restoreSessionState", - "Could not retrieve existing authentication credentials.", + logger.fe( + "Credentials could not be read!", + className: runtimeType.toString(), + methodName: 'restoreSession', ); throw Exception( "User should be authenticated but no authentication information was found.", ); } - - _debugPrintMessage( - "restoreSessionState", - "Authentication credentials successfully retrieved.", + logger.fd( + "Credentials successfully retrieved.", + className: runtimeType.toString(), + methodName: 'restoreSession', ); - _debugPrintMessage( - "restoreSessionState", - "Updating current session state...", + logger.fd( + "Updating security context...", + className: runtimeType.toString(), + methodName: 'restoreSession', ); _sessionManager.updateSettings( @@ -310,9 +332,10 @@ class AuthenticationCubit extends Cubit { authToken: authentication.token, baseUrl: localUserAccount.serverUrl, ); - _debugPrintMessage( - "restoreSessionState", - "Current session state successfully updated.", + logger.fd( + "Security context successfully updated.", + className: runtimeType.toString(), + methodName: 'restoreSession', ); final isPaperlessServerReachable = await _connectivityService.isPaperlessServerReachable( @@ -320,65 +343,95 @@ class AuthenticationCubit extends Cubit { authentication.clientCertificate, ) == ReachabilityStatus.reachable; + logger.fd( + "Trying to update remote paperless user...", + className: runtimeType.toString(), + methodName: 'restoreSession', + ); if (isPaperlessServerReachable) { - _debugPrintMessage( - "restoreSessionMState", - "Updating server user...", - ); final apiVersion = await _getApiVersion(_sessionManager.client); await _updateRemoteUser( _sessionManager, localUserAccount, apiVersion, ); - _debugPrintMessage( - "restoreSessionMState", - "Successfully updated server user.", + logger.fd( + "Successfully updated remote paperless user.", + className: runtimeType.toString(), + methodName: 'restoreSession', ); } else { - _debugPrintMessage( - "restoreSessionMState", - "Skipping update of server user (server could not be reached).", + logger.fw( + "Could not update remote paperless user - " + "Server could not be reached. The app might behave unexpected!", + className: runtimeType.toString(), + methodName: 'restoreSession', ); } globalSettings.loggedInUserId = restoreSessionForUser; await globalSettings.save(); emit(AuthenticatedState(localUserId: restoreSessionForUser)); - _debugPrintMessage( - "restoreSessionState", - "Session was successfully restored.", + logger.fd( + "Previous session successfully restored.", + className: runtimeType.toString(), + methodName: 'restoreSession', ); } - Future logout([bool removeAccount = false]) async { + Future logout([bool shouldRemoveAccount = false]) async { emit(const LoggingOutState()); - _debugPrintMessage( - "logout", - "Trying to log out current user...", - ); - await _resetExternalState(); final globalSettings = Hive.globalSettingsBox.getValue()!; final userId = globalSettings.loggedInUserId!; + final redactedId = redactUserId(userId); + + logger.fd( + "Logging out $redactedId...", + className: runtimeType.toString(), + methodName: 'logout', + ); + + await _resetExternalState(); await _notificationService.cancelUserNotifications(userId); final otherAccountsExist = Hive.localUserAccountBox.length > 1; emit(UnauthenticatedState(redirectToAccountSelection: otherAccountsExist)); - if (removeAccount) { - await this.removeAccount(userId); + if (shouldRemoveAccount) { + await removeAccount(userId); } globalSettings.loggedInUserId = null; await globalSettings.save(); - _debugPrintMessage( - "logout", + logger.fd( "User successfully logged out.", + className: runtimeType.toString(), + methodName: 'logout', ); } Future _resetExternalState() async { + logger.fd( + "Resetting security context...", + className: runtimeType.toString(), + methodName: '_resetExternalState', + ); _sessionManager.resetSettings(); + logger.fd( + "Security context reset.", + className: runtimeType.toString(), + methodName: '_resetExternalState', + ); + logger.fd( + "Clearing local state...", + className: runtimeType.toString(), + methodName: '_resetExternalState', + ); await HydratedBloc.storage.clear(); + logger.fd( + "Local state cleard.", + className: runtimeType.toString(), + methodName: '_resetExternalState', + ); } Future _addUser( @@ -392,7 +445,13 @@ class AuthenticationCubit extends Cubit { _FutureVoidCallback? onFetchUserInformation, }) async { assert(credentials.username != null && credentials.password != null); - _debugPrintMessage("_addUser", "Adding new user $localUserId..."); + final redactedId = redactUserId(localUserId); + + logger.fd( + "Adding new user $redactedId..", + className: runtimeType.toString(), + methodName: '_addUser', + ); sessionManager.updateSettings( baseUrl: serverUrl, @@ -401,9 +460,10 @@ class AuthenticationCubit extends Cubit { final authApi = _apiFactory.createAuthenticationApi(sessionManager.client); - _debugPrintMessage( - "_addUser", - "Trying to login user ${credentials.username} on $serverUrl...", + logger.fd( + "Fetching bearer token from the server...", + className: runtimeType.toString(), + methodName: '_addUser', ); await onPerformLogin?.call(); @@ -413,9 +473,10 @@ class AuthenticationCubit extends Cubit { password: credentials.password!, ); - _debugPrintMessage( - "_addUser", - "Successfully acquired token.", + logger.fd( + "Bearer token successfully retrieved.", + className: runtimeType.toString(), + methodName: '_addUser', ); sessionManager.updateSettings( @@ -430,18 +491,21 @@ class AuthenticationCubit extends Cubit { Hive.box(HiveBoxes.localUserAppState); if (userAccountBox.containsKey(localUserId)) { - _debugPrintMessage( - "_addUser", - "An error occurred! The user $localUserId already exists.", + logger.fw( + "The user $redactedId already exists.", + className: runtimeType.toString(), + methodName: '_addUser', ); throw InfoMessageException(code: ErrorCode.userAlreadyExists); } await onFetchUserInformation?.call(); final apiVersion = await _getApiVersion(sessionManager.client); - _debugPrintMessage( - "_addUser", - "Trying to fetch user object for $localUserId...", + logger.fd( + "Trying to fetch remote paperless user for $redactedId.", + className: runtimeType.toString(), + methodName: '_addUser', ); + late UserModel serverUser; try { serverUser = await _apiFactory @@ -451,21 +515,28 @@ class AuthenticationCubit extends Cubit { ) .findCurrentUser(); } on DioException catch (error, stackTrace) { - _debugPrintMessage( - "_addUser", - "An error occurred: ${error.message}", + logger.fe( + "An error occurred while fetching the remote paperless user.", + className: runtimeType.toString(), + methodName: '_addUser', + error: error, stackTrace: stackTrace, ); + rethrow; } - _debugPrintMessage( - "_addUser", - "User object successfully fetched.", + logger.fd( + "Remote paperless user successfully fetched.", + className: runtimeType.toString(), + methodName: '_addUser', ); - _debugPrintMessage( - "_addUser", - "Persisting local user account...", + + logger.fd( + "Persisting user account information...", + className: runtimeType.toString(), + methodName: '_addUser', ); + await onPersistLocalUserData?.call(); // Create user account await userAccountBox.put( @@ -478,29 +549,34 @@ class AuthenticationCubit extends Cubit { apiVersion: apiVersion, ), ); - _debugPrintMessage( - "_addUser", - "Local user account successfully persisted.", + logger.fd( + "User account information successfully persisted.", + className: runtimeType.toString(), + methodName: '_addUser', ); - _debugPrintMessage( - "_addUser", - "Persisting user state...", + logger.fd( + "Persisting user app state...", + className: runtimeType.toString(), + methodName: '_addUser', ); // Create user state await userStateBox.put( localUserId, LocalUserAppState(userId: localUserId), ); - _debugPrintMessage( - "_addUser", + logger.fd( "User state successfully persisted.", + className: runtimeType.toString(), + methodName: '_addUser', ); // Save credentials in encrypted box await withEncryptedBox(HiveBoxes.localUserCredentials, (box) async { - _debugPrintMessage( - "_addUser", + logger.fd( "Saving user credentials inside encrypted storage...", + className: runtimeType.toString(), + methodName: '_addUser', ); + await box.put( localUserId, UserCredentials( @@ -508,14 +584,20 @@ class AuthenticationCubit extends Cubit { clientCertificate: clientCert, ), ); - _debugPrintMessage( - "_addUser", + logger.fd( "User credentials successfully saved.", + className: runtimeType.toString(), + methodName: '_addUser', ); }); final hostsBox = Hive.box(HiveBoxes.hosts); if (!hostsBox.values.contains(serverUrl)) { await hostsBox.add(serverUrl); + logger.fd( + "Added new url to list of hosts.", + className: runtimeType.toString(), + methodName: '_addUser', + ); } return serverUser.id; @@ -526,9 +608,10 @@ class AuthenticationCubit extends Cubit { Duration? timeout, int defaultValue = 2, }) async { - _debugPrintMessage( - "_getApiVersion", + logger.fd( "Trying to fetch API version...", + className: runtimeType.toString(), + methodName: '_getApiVersion', ); try { final response = await dio.get( @@ -539,12 +622,19 @@ class AuthenticationCubit extends Cubit { ); final apiVersion = int.parse(response.headers.value('x-api-version') ?? "3"); - _debugPrintMessage( - "_getApiVersion", - "API version ($apiVersion) successfully retrieved.", + logger.fd( + "Successfully retrieved API version ($apiVersion).", + className: runtimeType.toString(), + methodName: '_getApiVersion', ); + return apiVersion; } on DioException catch (_) { + logger.fw( + "Could not retrieve API version, using default ($defaultValue).", + className: runtimeType.toString(), + methodName: '_getApiVersion', + ); return defaultValue; } } @@ -555,37 +645,21 @@ class AuthenticationCubit extends Cubit { LocalUserAccount localUserAccount, int apiVersion, ) async { - _debugPrintMessage( - "_updateRemoteUser", - "Updating paperless user object...", + logger.fd( + "Trying to update remote user object...", + className: runtimeType.toString(), + methodName: '_updateRemoteUser', ); final updatedPaperlessUser = await _apiFactory - .createUserApi( - sessionManager.client, - apiVersion: apiVersion, - ) + .createUserApi(sessionManager.client, apiVersion: apiVersion) .findCurrentUser(); localUserAccount.paperlessUser = updatedPaperlessUser; await localUserAccount.save(); - _debugPrintMessage( - "_updateRemoteUser", - "Paperless user object successfully updated.", + logger.fd( + "Successfully updated remote user object.", + className: runtimeType.toString(), + methodName: '_updateRemoteUser', ); } - - void _debugPrintMessage( - String methodName, - String message, { - Object? error, - StackTrace? stackTrace, - }) { - debugPrint("AuthenticationCubit#$methodName: $message"); - if (error != null) { - debugPrint(error.toString()); - } - if (stackTrace != null) { - debugPrintStack(stackTrace: stackTrace); - } - } } diff --git a/lib/features/login/model/authentication_information.dart b/lib/features/login/model/authentication_information.dart index 1743f1f..b83144e 100644 --- a/lib/features/login/model/authentication_information.dart +++ b/lib/features/login/model/authentication_information.dart @@ -1,5 +1,5 @@ import 'package:hive/hive.dart'; -import 'package:paperless_mobile/core/config/hive/hive_config.dart'; +import 'package:paperless_mobile/core/database/hive/hive_config.dart'; import 'package:paperless_mobile/features/login/model/client_certificate.dart'; part 'authentication_information.g.dart'; diff --git a/lib/features/login/model/client_certificate.dart b/lib/features/login/model/client_certificate.dart index 00c24c8..3dd39b8 100644 --- a/lib/features/login/model/client_certificate.dart +++ b/lib/features/login/model/client_certificate.dart @@ -1,7 +1,7 @@ import 'dart:typed_data'; import 'package:hive_flutter/adapters.dart'; -import 'package:paperless_mobile/core/config/hive/hive_config.dart'; +import 'package:paperless_mobile/core/database/hive/hive_config.dart'; part 'client_certificate.g.dart'; diff --git a/lib/features/login/view/add_account_page.dart b/lib/features/login/view/add_account_page.dart index e7ab5b6..07eab01 100644 --- a/lib/features/login/view/add_account_page.dart +++ b/lib/features/login/view/add_account_page.dart @@ -7,7 +7,7 @@ import 'package:paperless_api/paperless_api.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/service/connectivity_status_service.dart'; -import 'package:paperless_mobile/extensions/flutter_extensions.dart'; +import 'package:paperless_mobile/core/extensions/flutter_extensions.dart'; import 'package:paperless_mobile/features/login/model/client_certificate.dart'; import 'package:paperless_mobile/features/login/model/client_certificate_form_model.dart'; import 'package:paperless_mobile/features/login/model/login_form_credentials.dart'; @@ -84,40 +84,42 @@ class _AddAccountPageState extends State { ), ), 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), - ] - ], + body: AutofillGroup( + child: 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), + ] + ], + ), ), ), ); diff --git a/lib/features/login/view/login_page.dart b/lib/features/login/view/login_page.dart index 831be9e..fe92a88 100644 --- a/lib/features/login/view/login_page.dart +++ b/lib/features/login/view/login_page.dart @@ -2,8 +2,8 @@ import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:hive_flutter/adapters.dart'; import 'package:paperless_api/paperless_api.dart'; -import 'package:paperless_mobile/core/config/hive/hive_config.dart'; -import 'package:paperless_mobile/core/config/hive/hive_extensions.dart'; +import 'package:paperless_mobile/core/database/hive/hive_config.dart'; +import 'package:paperless_mobile/core/database/hive/hive_extensions.dart'; import 'package:paperless_mobile/core/database/tables/global_settings.dart'; import 'package:paperless_mobile/core/model/info_message_exception.dart'; import 'package:paperless_mobile/features/app_intro/application_intro_slideshow.dart'; diff --git a/lib/features/login/view/login_to_existing_account_page.dart b/lib/features/login/view/login_to_existing_account_page.dart index 952c213..c4aa7de 100644 --- a/lib/features/login/view/login_to_existing_account_page.dart +++ b/lib/features/login/view/login_to_existing_account_page.dart @@ -1,7 +1,7 @@ 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/core/database/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'; diff --git a/lib/features/login/view/verify_identity_page.dart b/lib/features/login/view/verify_identity_page.dart index 455becc..00ed75c 100644 --- a/lib/features/login/view/verify_identity_page.dart +++ b/lib/features/login/view/verify_identity_page.dart @@ -1,5 +1,5 @@ import 'package:flutter/material.dart'; -import 'package:paperless_mobile/extensions/flutter_extensions.dart'; +import 'package:paperless_mobile/core/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/shells/authenticated_route.dart'; diff --git a/lib/features/login/view/widgets/form_fields/client_certificate_form_field.dart b/lib/features/login/view/widgets/form_fields/client_certificate_form_field.dart index b0c3753..b29a2f7 100644 --- a/lib/features/login/view/widgets/form_fields/client_certificate_form_field.dart +++ b/lib/features/login/view/widgets/form_fields/client_certificate_form_field.dart @@ -4,7 +4,7 @@ import 'dart:typed_data'; import 'package:file_picker/file_picker.dart'; 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/core/extensions/flutter_extensions.dart'; import 'package:paperless_mobile/features/login/model/client_certificate_form_model.dart'; import 'package:paperless_mobile/generated/l10n/app_localizations.dart'; diff --git a/lib/features/login/view/widgets/form_fields/server_address_form_field.dart b/lib/features/login/view/widgets/form_fields/server_address_form_field.dart index cff93d8..207d74c 100644 --- a/lib/features/login/view/widgets/form_fields/server_address_form_field.dart +++ b/lib/features/login/view/widgets/form_fields/server_address_form_field.dart @@ -2,7 +2,7 @@ import 'package:flutter/material.dart'; import 'package:flutter/scheduler.dart'; import 'package:flutter_form_builder/flutter_form_builder.dart'; import 'package:hive_flutter/adapters.dart'; -import 'package:paperless_mobile/core/config/hive/hive_config.dart'; +import 'package:paperless_mobile/core/database/hive/hive_config.dart'; import 'package:paperless_mobile/generated/l10n/app_localizations.dart'; diff --git a/lib/features/login/view/widgets/form_fields/user_credentials_form_field.dart b/lib/features/login/view/widgets/form_fields/user_credentials_form_field.dart index 397d563..2f55bdc 100644 --- a/lib/features/login/view/widgets/form_fields/user_credentials_form_field.dart +++ b/lib/features/login/view/widgets/form_fields/user_credentials_form_field.dart @@ -1,9 +1,9 @@ import 'package:flutter/material.dart'; import 'package:flutter_form_builder/flutter_form_builder.dart'; import 'package:hive_flutter/adapters.dart'; -import 'package:paperless_mobile/core/config/hive/hive_extensions.dart'; +import 'package:paperless_mobile/core/database/hive/hive_extensions.dart'; -import 'package:paperless_mobile/extensions/flutter_extensions.dart'; +import 'package:paperless_mobile/core/extensions/flutter_extensions.dart'; import 'package:paperless_mobile/features/login/model/login_form_credentials.dart'; import 'package:paperless_mobile/features/login/view/widgets/form_fields/obscured_input_text_form_field.dart'; import 'package:paperless_mobile/features/login/view/widgets/form_fields/server_address_form_field.dart'; @@ -41,65 +41,62 @@ class _UserCredentialsFormFieldState extends State { username: widget.initialUsername, ), name: UserCredentialsFormField.fkCredentials, - builder: (field) => AutofillGroup( - child: Column( - children: [ - TextFormField( - key: const ValueKey('login-username'), - focusNode: _usernameFocusNode, - textCapitalization: TextCapitalization.none, - textInputAction: TextInputAction.next, - onFieldSubmitted: (value) { - _passwordFocusNode.requestFocus(); - }, - autovalidateMode: AutovalidateMode.onUserInteraction, - autocorrect: false, - onChanged: (username) => field.didChange( - field.value?.copyWith(username: username) ?? - LoginFormCredentials(username: username), - ), - validator: (value) { - if (value?.trim().isEmpty ?? true) { - return S.of(context)!.usernameMustNotBeEmpty; - } - final serverAddress = widget.formKey.currentState! - .getRawValue( - ServerAddressFormField.fkServerAddress); - if (serverAddress != null) { - final userExists = Hive.localUserAccountBox.values - .map((e) => e.id) - .contains('$value@$serverAddress'); - if (userExists) { - return S.of(context)!.userAlreadyExists; - } - } - return null; - }, - autofillHints: const [AutofillHints.username], - decoration: InputDecoration( - label: Text(S.of(context)!.username), - ), + builder: (field) => Column( + children: [ + TextFormField( + key: const ValueKey('login-username'), + focusNode: _usernameFocusNode, + textCapitalization: TextCapitalization.none, + textInputAction: TextInputAction.next, + onFieldSubmitted: (value) { + _passwordFocusNode.requestFocus(); + }, + autovalidateMode: AutovalidateMode.onUserInteraction, + autocorrect: false, + onChanged: (username) => field.didChange( + field.value?.copyWith(username: username) ?? + LoginFormCredentials(username: username), ), - ObscuredInputTextFormField( - key: const ValueKey('login-password'), - focusNode: _passwordFocusNode, - label: S.of(context)!.password, - onChanged: (password) => field.didChange( - field.value?.copyWith(password: password) ?? - LoginFormCredentials(password: password), - ), - onFieldSubmitted: (_) { - widget.onFieldsSubmitted(); - }, - validator: (value) { - if (value?.trim().isEmpty ?? true) { - return S.of(context)!.passwordMustNotBeEmpty; + validator: (value) { + if (value?.trim().isEmpty ?? true) { + return S.of(context)!.usernameMustNotBeEmpty; + } + final serverAddress = widget.formKey.currentState! + .getRawValue(ServerAddressFormField.fkServerAddress); + if (serverAddress != null) { + final userExists = Hive.localUserAccountBox.values + .map((e) => e.id) + .contains('$value@$serverAddress'); + if (userExists) { + return S.of(context)!.userAlreadyExists; } - return null; - }, + } + return null; + }, + autofillHints: const [AutofillHints.username], + decoration: InputDecoration( + label: Text(S.of(context)!.username), ), - ].map((child) => child.padded()).toList(), - ), + ), + ObscuredInputTextFormField( + key: const ValueKey('login-password'), + focusNode: _passwordFocusNode, + label: S.of(context)!.password, + onChanged: (password) => field.didChange( + field.value?.copyWith(password: password) ?? + LoginFormCredentials(password: password), + ), + onFieldSubmitted: (_) { + widget.onFieldsSubmitted(); + }, + validator: (value) { + if (value?.trim().isEmpty ?? true) { + return S.of(context)!.passwordMustNotBeEmpty; + } + return null; + }, + ), + ].map((child) => child.padded()).toList(), ), ); } diff --git a/lib/features/login/view/widgets/login_transition_page.dart b/lib/features/login/view/widgets/login_transition_page.dart index 3976418..5bbcb19 100644 --- a/lib/features/login/view/widgets/login_transition_page.dart +++ b/lib/features/login/view/widgets/login_transition_page.dart @@ -1,6 +1,6 @@ import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; -import 'package:paperless_mobile/extensions/flutter_extensions.dart'; +import 'package:paperless_mobile/core/extensions/flutter_extensions.dart'; import 'package:paperless_mobile/theme.dart'; class LoginTransitionPage extends StatelessWidget { diff --git a/lib/features/notifications/converters/notification_tap_response_payload.dart b/lib/features/notifications/converters/notification_tap_response_payload.dart index 93e06ae..9ba69f7 100644 --- a/lib/features/notifications/converters/notification_tap_response_payload.dart +++ b/lib/features/notifications/converters/notification_tap_response_payload.dart @@ -1,7 +1,7 @@ import 'package:json_annotation/json_annotation.dart'; import 'package:paperless_mobile/features/notifications/models/notification_actions.dart'; import 'package:paperless_mobile/features/notifications/models/notification_payloads/notification_tap/notification_tap_response_payload.dart'; -import 'package:paperless_mobile/features/notifications/models/notification_payloads/notification_tap/open_downloaded_document_payload.dart'; +import 'package:paperless_mobile/features/notifications/models/notification_payloads/notification_tap/open_directory_notification_response_payload.dart'; class NotificationTapResponsePayloadConverter implements @@ -11,8 +11,8 @@ class NotificationTapResponsePayloadConverter NotificationTapResponsePayload fromJson(Map json) { final type = NotificationResponseOpenAction.values.byName(json['type']); switch (type) { - case NotificationResponseOpenAction.openDownloadedDocumentPath: - return OpenDownloadedDocumentPayload.fromJson( + case NotificationResponseOpenAction.openDirectory: + return OpenDirectoryNotificationResponsePayload.fromJson( json, ); } diff --git a/lib/features/notifications/models/notification_actions.dart b/lib/features/notifications/models/notification_actions.dart index f7f6662..ce632cd 100644 --- a/lib/features/notifications/models/notification_actions.dart +++ b/lib/features/notifications/models/notification_actions.dart @@ -7,5 +7,5 @@ enum NotificationResponseButtonAction { @JsonEnum() enum NotificationResponseOpenAction { - openDownloadedDocumentPath; + openDirectory; } diff --git a/lib/features/notifications/models/notification_channels.dart b/lib/features/notifications/models/notification_channels.dart index 3b8c431..7e49ec5 100644 --- a/lib/features/notifications/models/notification_channels.dart +++ b/lib/features/notifications/models/notification_channels.dart @@ -1,6 +1,7 @@ enum NotificationChannel { task("task_channel", "Paperless tasks"), - documentDownload("document_download_channel", "Document downloads"); + documentDownload("document_download_channel", "Document downloads"), + fileDownload("file_download_channel", "File downloads"); final String id; final String name; diff --git a/lib/features/notifications/models/notification_payloads/notification_tap/open_directory_notification_response_payload.dart b/lib/features/notifications/models/notification_payloads/notification_tap/open_directory_notification_response_payload.dart new file mode 100644 index 0000000..0640663 --- /dev/null +++ b/lib/features/notifications/models/notification_payloads/notification_tap/open_directory_notification_response_payload.dart @@ -0,0 +1,22 @@ +import 'package:json_annotation/json_annotation.dart'; +import 'package:paperless_mobile/features/notifications/models/notification_actions.dart'; +import 'package:paperless_mobile/features/notifications/models/notification_payloads/notification_tap/notification_tap_response_payload.dart'; + +part 'open_directory_notification_response_payload.g.dart'; + +@JsonSerializable() +class OpenDirectoryNotificationResponsePayload + extends NotificationTapResponsePayload { + final String filePath; + OpenDirectoryNotificationResponsePayload({ + required this.filePath, + super.type = NotificationResponseOpenAction.openDirectory, + }); + + factory OpenDirectoryNotificationResponsePayload.fromJson( + Map json) => + _$OpenDirectoryNotificationResponsePayloadFromJson(json); + @override + Map toJson() => + _$OpenDirectoryNotificationResponsePayloadToJson(this); +} diff --git a/lib/features/notifications/models/notification_payloads/notification_tap/open_downloaded_document_payload.dart b/lib/features/notifications/models/notification_payloads/notification_tap/open_downloaded_document_payload.dart deleted file mode 100644 index 6612a13..0000000 --- a/lib/features/notifications/models/notification_payloads/notification_tap/open_downloaded_document_payload.dart +++ /dev/null @@ -1,19 +0,0 @@ -import 'package:json_annotation/json_annotation.dart'; -import 'package:paperless_mobile/features/notifications/models/notification_actions.dart'; -import 'package:paperless_mobile/features/notifications/models/notification_payloads/notification_tap/notification_tap_response_payload.dart'; - -part 'open_downloaded_document_payload.g.dart'; - -@JsonSerializable() -class OpenDownloadedDocumentPayload extends NotificationTapResponsePayload { - final String filePath; - OpenDownloadedDocumentPayload({ - required this.filePath, - super.type = NotificationResponseOpenAction.openDownloadedDocumentPath, - }); - - factory OpenDownloadedDocumentPayload.fromJson(Map json) => - _$OpenDownloadedDocumentPayloadFromJson(json); - @override - Map toJson() => _$OpenDownloadedDocumentPayloadToJson(this); -} diff --git a/lib/features/notifications/services/local_notification_service.dart b/lib/features/notifications/services/local_notification_service.dart index bfad7fc..f94a2a5 100644 --- a/lib/features/notifications/services/local_notification_service.dart +++ b/lib/features/notifications/services/local_notification_service.dart @@ -6,10 +6,10 @@ import 'package:flutter_local_notifications/flutter_local_notifications.dart'; import 'package:open_filex/open_filex.dart'; import 'package:paperless_api/paperless_api.dart'; import 'package:paperless_mobile/features/notifications/converters/notification_tap_response_payload.dart'; -import 'package:paperless_mobile/features/notifications/models/notification_payloads/notification_action/create_document_success_payload.dart'; -import 'package:paperless_mobile/features/notifications/models/notification_payloads/notification_tap/open_downloaded_document_payload.dart'; import 'package:paperless_mobile/features/notifications/models/notification_actions.dart'; import 'package:paperless_mobile/features/notifications/models/notification_channels.dart'; +import 'package:paperless_mobile/features/notifications/models/notification_payloads/notification_action/create_document_success_payload.dart'; +import 'package:paperless_mobile/features/notifications/models/notification_payloads/notification_tap/open_directory_notification_response_payload.dart'; import 'package:paperless_mobile/generated/l10n/app_localizations.dart'; class LocalNotificationService { @@ -48,6 +48,31 @@ class LocalNotificationService { } Future notifyFileDownload({ + required String filePath, + }) async { + await _plugin.show( + filePath.hashCode, + filePath, + "File download complete.", + NotificationDetails( + android: AndroidNotificationDetails( + NotificationChannel.fileDownload.id + "_${filePath.hashCode}", + NotificationChannel.fileDownload.name, + importance: Importance.max, + priority: Priority.high, + showProgress: false, + when: DateTime.now().millisecondsSinceEpoch, + category: AndroidNotificationCategory.status, + icon: 'file_download_done', + ), + ), + payload: jsonEncode( + OpenDirectoryNotificationResponsePayload(filePath: filePath) + .toJson()), + ); + } + + Future notifyDocumentDownload({ required DocumentModel document, required String filename, required String filePath, @@ -89,7 +114,7 @@ class LocalNotificationService { ), ), payload: jsonEncode( - OpenDownloadedDocumentPayload( + OpenDirectoryNotificationResponsePayload( filePath: filePath, ).toJson(), ), @@ -139,7 +164,7 @@ class LocalNotificationService { ), ), payload: jsonEncode( - OpenDownloadedDocumentPayload(filePath: filePath).toJson(), + OpenDirectoryNotificationResponsePayload(filePath: filePath).toJson(), ), ); } @@ -281,9 +306,10 @@ class LocalNotificationService { NotificationResponse response, ) { switch (type) { - case NotificationResponseOpenAction.openDownloadedDocumentPath: - final payload = OpenDownloadedDocumentPayload.fromJson( - jsonDecode(response.payload!)); + case NotificationResponseOpenAction.openDirectory: + final payload = OpenDirectoryNotificationResponsePayload.fromJson( + jsonDecode(response.payload!), + ); OpenFilex.open(payload.filePath); break; } diff --git a/lib/features/paged_document_view/cubit/document_paging_bloc_mixin.dart b/lib/features/paged_document_view/cubit/document_paging_bloc_mixin.dart index 3c9cd67..8377544 100644 --- a/lib/features/paged_document_view/cubit/document_paging_bloc_mixin.dart +++ b/lib/features/paged_document_view/cubit/document_paging_bloc_mixin.dart @@ -1,4 +1,5 @@ import 'package:collection/collection.dart'; +import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:paperless_api/paperless_api.dart'; import 'package:paperless_mobile/core/notifier/document_changed_notifier.dart'; @@ -21,11 +22,12 @@ mixin DocumentPagingBlocMixin Future loadMore() async { final hasConnection = await connectivityStatusService.isConnectedToInternet(); - if (state.isLastPageLoaded || !hasConnection) { + if (state.isLastPageLoaded || !hasConnection || state.isLoading) { return; } emit(state.copyWithPaged(isLoading: true)); final newFilter = state.filter.copyWith(page: state.filter.page + 1); + debugPrint("Fetching page ${newFilter.page}"); try { final result = await api.findAll(newFilter); emit( @@ -217,7 +219,6 @@ mixin DocumentPagingBlocMixin } } - @override Future close() { notifier.removeListener(this); 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 2bd8fcd..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,5 +1,8 @@ import 'package:bloc/bloc.dart'; +import 'package:collection/collection.dart'; import 'package:paperless_api/paperless_api.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'; part 'saved_view_preview_state.dart'; @@ -8,11 +11,55 @@ class SavedViewPreviewCubit extends Cubit { final PaperlessDocumentsApi _api; final SavedView view; final ConnectivityStatusService _connectivityStatusService; + final DocumentChangedNotifier _changedNotifier; SavedViewPreviewCubit( this._api, - this._connectivityStatusService, { + this._connectivityStatusService, + this._changedNotifier, { required this.view, - }) : super(const InitialSavedViewPreviewState()); + }) : super(const InitialSavedViewPreviewState()) { + _changedNotifier.addListener( + this, + onDeleted: (document) { + final s = state; + if (s is! LoadedSavedViewPreviewState) { + return; + } + if (!s.documents.containsDocument(document)) { + return; + } + emit( + LoadedSavedViewPreviewState( + documents: s.documents.withDocumentRemoved(document).toList(), + ), + ); + }, + onUpdated: (document) { + final s = state; + if (s is! LoadedSavedViewPreviewState) { + return; + } + if (!s.documents.containsDocument(document)) { + return; + } + + final shouldRemainInFilter = view.toDocumentFilter().matches(document); + if (!shouldRemainInFilter) { + emit( + LoadedSavedViewPreviewState( + documents: s.documents.withDocumentRemoved(document).toList(), + ), + ); + } else { + emit( + LoadedSavedViewPreviewState( + documents: s.documents.withDocumentreplaced(document).toList(), + ), + ); + } + }, + ); + } Future initialize() async { final isConnected = 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 31c5103..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,7 +1,8 @@ import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:paperless_api/paperless_api.dart'; -import 'package:paperless_mobile/extensions/flutter_extensions.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'; import 'package:paperless_mobile/features/landing/view/widgets/expansion_card.dart'; @@ -24,6 +25,7 @@ class SavedViewPreview extends StatelessWidget { Widget build(BuildContext context) { return Provider( create: (context) => SavedViewPreviewCubit( + context.read(), context.read(), context.read(), view: savedView, @@ -54,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/settings/model/color_scheme_option.dart b/lib/features/settings/model/color_scheme_option.dart index d1d1327..c63e745 100644 --- a/lib/features/settings/model/color_scheme_option.dart +++ b/lib/features/settings/model/color_scheme_option.dart @@ -1,5 +1,5 @@ import 'package:hive_flutter/adapters.dart'; -import 'package:paperless_mobile/core/config/hive/hive_config.dart'; +import 'package:paperless_mobile/core/database/hive/hive_config.dart'; part 'color_scheme_option.g.dart'; diff --git a/lib/features/settings/model/file_download_type.dart b/lib/features/settings/model/file_download_type.dart index 3742539..b475399 100644 --- a/lib/features/settings/model/file_download_type.dart +++ b/lib/features/settings/model/file_download_type.dart @@ -1,5 +1,5 @@ import 'package:hive/hive.dart'; -import 'package:paperless_mobile/core/config/hive/hive_config.dart'; +import 'package:paperless_mobile/core/database/hive/hive_config.dart'; part 'file_download_type.g.dart'; diff --git a/lib/features/settings/model/view_type.dart b/lib/features/settings/model/view_type.dart index 72b13df..e8ad7d7 100644 --- a/lib/features/settings/model/view_type.dart +++ b/lib/features/settings/model/view_type.dart @@ -1,5 +1,5 @@ import 'package:hive/hive.dart'; -import 'package:paperless_mobile/core/config/hive/hive_config.dart'; +import 'package:paperless_mobile/core/database/hive/hive_config.dart'; part 'view_type.g.dart'; diff --git a/lib/features/settings/view/manage_accounts_page.dart b/lib/features/settings/view/manage_accounts_page.dart index b06fc66..1c0ac86 100644 --- a/lib/features/settings/view/manage_accounts_page.dart +++ b/lib/features/settings/view/manage_accounts_page.dart @@ -1,7 +1,7 @@ import 'package:collection/collection.dart'; import 'package:flutter/material.dart'; import 'package:hive_flutter/adapters.dart'; -import 'package:paperless_mobile/core/config/hive/hive_extensions.dart'; +import 'package:paperless_mobile/core/database/hive/hive_extensions.dart'; import 'package:paperless_mobile/features/login/cubit/authentication_cubit.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'; diff --git a/lib/features/settings/view/settings_page.dart b/lib/features/settings/view/settings_page.dart index 5be033c..9f01e07 100644 --- a/lib/features/settings/view/settings_page.dart +++ b/lib/features/settings/view/settings_page.dart @@ -1,6 +1,8 @@ import 'package:flutter/material.dart'; import 'package:paperless_api/paperless_api.dart'; +import 'package:paperless_mobile/features/settings/view/widgets/app_logs_tile.dart'; import 'package:paperless_mobile/features/settings/view/widgets/biometric_authentication_setting.dart'; +import 'package:paperless_mobile/features/settings/view/widgets/changelogs_tile.dart'; import 'package:paperless_mobile/features/settings/view/widgets/clear_storage_settings.dart'; import 'package:paperless_mobile/features/settings/view/widgets/color_scheme_option_setting.dart'; import 'package:paperless_mobile/features/settings/view/widgets/default_download_file_type_setting.dart'; @@ -37,6 +39,9 @@ class SettingsPage extends StatelessWidget { const SkipDocumentPreprationOnShareSetting(), _buildSectionHeader(context, S.of(context)!.storage), const ClearCacheSetting(), + _buildSectionHeader(context, S.of(context)!.misc), + const AppLogsTile(), + const ChangelogsTile(), ], ), bottomNavigationBar: UserAccountBuilder( diff --git a/lib/features/settings/view/widgets/app_logs_tile.dart b/lib/features/settings/view/widgets/app_logs_tile.dart new file mode 100644 index 0000000..aee7d55 --- /dev/null +++ b/lib/features/settings/view/widgets/app_logs_tile.dart @@ -0,0 +1,18 @@ +import 'package:flutter/material.dart'; +import 'package:paperless_mobile/generated/l10n/app_localizations.dart'; +import 'package:paperless_mobile/routes/typed/top_level/app_logs_route.dart'; + +class AppLogsTile extends StatelessWidget { + const AppLogsTile({super.key}); + + @override + Widget build(BuildContext context) { + return ListTile( + leading: const Icon(Icons.subject), + title: Text(S.of(context)!.appLogs('')), + onTap: () { + AppLogsRoute().push(context); + }, + ); + } +} diff --git a/lib/features/settings/view/widgets/changelogs_tile.dart b/lib/features/settings/view/widgets/changelogs_tile.dart new file mode 100644 index 0000000..747b530 --- /dev/null +++ b/lib/features/settings/view/widgets/changelogs_tile.dart @@ -0,0 +1,18 @@ +import 'package:flutter/material.dart'; +import 'package:paperless_mobile/generated/l10n/app_localizations.dart'; +import 'package:paperless_mobile/routes/typed/top_level/changelog_route.dart'; + +class ChangelogsTile extends StatelessWidget { + const ChangelogsTile({super.key}); + + @override + Widget build(BuildContext context) { + return ListTile( + leading: const Icon(Icons.history), + title: Text(S.of(context)!.changelog), + onTap: () { + ChangelogRoute().push(context); + }, + ); + } +} diff --git a/lib/features/settings/view/widgets/clear_storage_settings.dart b/lib/features/settings/view/widgets/clear_storage_settings.dart index b5aa166..9d3df1c 100644 --- a/lib/features/settings/view/widgets/clear_storage_settings.dart +++ b/lib/features/settings/view/widgets/clear_storage_settings.dart @@ -18,43 +18,25 @@ class _ClearCacheSettingState extends State { Widget build(BuildContext context) { return ListTile( title: Text(S.of(context)!.clearCache), - subtitle: FutureBuilder( - future: FileService.temporaryDirectory.then(_dirSize), + subtitle: FutureBuilder( + future: FileService.instance + .getDirSizeInBytes(FileService.instance.temporaryDirectory), builder: (context, snapshot) { if (!snapshot.hasData) { return Text(S.of(context)!.calculatingDots); } - return Text(S.of(context)!.freeBytes(snapshot.data!)); + final dirSize = formatBytes(snapshot.data!); + return Text(S.of(context)!.freeBytes(dirSize)); }, ), onTap: () async { - final dir = await FileService.temporaryDirectory; - final deletedSize = await _dirSize(dir); - await dir.delete(recursive: true); + final freedBytes = await FileService.instance + .clearDirectoryContent(PaperlessDirectoryType.temporary); showSnackBar( context, - S.of(context)!.freedDiskSpace(deletedSize), + S.of(context)!.freedDiskSpace(formatBytes(freedBytes)), ); }, ); } } - -Future _dirSize(Directory dir) async { - int totalSize = 0; - try { - if (await dir.exists()) { - dir - .listSync(recursive: true, followLinks: false) - .forEach((FileSystemEntity entity) async { - if (entity is File) { - totalSize += (await entity.length()); - } - }); - } - } catch (error) { - debugPrint(error.toString()); - } - - return formatBytes(totalSize, 0); -} diff --git a/lib/features/settings/view/widgets/global_settings_builder.dart b/lib/features/settings/view/widgets/global_settings_builder.dart index 1df0049..6601738 100644 --- a/lib/features/settings/view/widgets/global_settings_builder.dart +++ b/lib/features/settings/view/widgets/global_settings_builder.dart @@ -1,6 +1,6 @@ import 'package:flutter/material.dart'; import 'package:hive_flutter/adapters.dart'; -import 'package:paperless_mobile/core/config/hive/hive_config.dart'; +import 'package:paperless_mobile/core/database/hive/hive_config.dart'; import 'package:paperless_mobile/core/database/tables/global_settings.dart'; class GlobalSettingsBuilder extends StatelessWidget { diff --git a/lib/features/settings/view/widgets/language_selection_setting.dart b/lib/features/settings/view/widgets/language_selection_setting.dart index 1a1e3cc..cb18576 100644 --- a/lib/features/settings/view/widgets/language_selection_setting.dart +++ b/lib/features/settings/view/widgets/language_selection_setting.dart @@ -21,7 +21,7 @@ class _LanguageSelectionSettingState extends State { 'cs': LanguageOption('Česky', true), 'tr': LanguageOption('Türkçe', true), 'pl': LanguageOption('Polska', true), - 'ca': LanguageOption('Catalan', true), + 'ca': LanguageOption('Català', true), 'ru': LanguageOption('Русский', true), }; diff --git a/lib/features/settings/view/widgets/user_settings_builder.dart b/lib/features/settings/view/widgets/user_settings_builder.dart index 2201a58..ca4b3ab 100644 --- a/lib/features/settings/view/widgets/user_settings_builder.dart +++ b/lib/features/settings/view/widgets/user_settings_builder.dart @@ -1,6 +1,6 @@ import 'package:flutter/material.dart'; import 'package:hive_flutter/adapters.dart'; -import 'package:paperless_mobile/core/config/hive/hive_config.dart'; +import 'package:paperless_mobile/core/database/hive/hive_config.dart'; import 'package:paperless_mobile/core/database/tables/local_user_account.dart'; import 'package:paperless_mobile/core/database/tables/global_settings.dart'; diff --git a/lib/features/sharing/cubit/receive_share_cubit.dart b/lib/features/sharing/cubit/receive_share_cubit.dart index facaada..7979dde 100644 --- a/lib/features/sharing/cubit/receive_share_cubit.dart +++ b/lib/features/sharing/cubit/receive_share_cubit.dart @@ -32,7 +32,7 @@ class ConsumptionChangeNotifier extends ChangeNotifier { return []; } final consumptionDirectory = - await FileService.getConsumptionDirectory(userId: userId); + await FileService.instance.getConsumptionDirectory(userId: userId); final List localFiles = []; for (final file in files) { if (!file.path.startsWith(consumptionDirectory.path)) { @@ -53,7 +53,7 @@ class ConsumptionChangeNotifier extends ChangeNotifier { required String userId, }) async { final consumptionDirectory = - await FileService.getConsumptionDirectory(userId: userId); + await FileService.instance.getConsumptionDirectory(userId: userId); if (file.path.startsWith(consumptionDirectory.path)) { await file.delete(); } @@ -70,8 +70,8 @@ class ConsumptionChangeNotifier extends ChangeNotifier { } Future> _getCurrentFiles(String userId) async { - final directory = await FileService.getConsumptionDirectory(userId: userId); - final files = await FileService.getAllFiles(directory); - return files; + final directory = + await FileService.instance.getConsumptionDirectory(userId: userId); + return await FileService.instance.getAllFiles(directory); } } diff --git a/lib/features/sharing/view/widgets/event_listener_shell.dart b/lib/features/sharing/view/widgets/event_listener_shell.dart index ff637e2..1566e76 100644 --- a/lib/features/sharing/view/widgets/event_listener_shell.dart +++ b/lib/features/sharing/view/widgets/event_listener_shell.dart @@ -9,8 +9,8 @@ import 'package:fluttertoast/fluttertoast.dart'; import 'package:hive/hive.dart'; import 'package:paperless_api/paperless_api.dart'; import 'package:paperless_mobile/core/bloc/connectivity_cubit.dart'; -import 'package:paperless_mobile/core/config/hive/hive_config.dart'; -import 'package:paperless_mobile/core/config/hive/hive_extensions.dart'; +import 'package:paperless_mobile/core/database/hive/hive_config.dart'; +import 'package:paperless_mobile/core/database/hive/hive_extensions.dart'; import 'package:paperless_mobile/core/database/tables/local_user_account.dart'; import 'package:paperless_mobile/core/notifier/document_changed_notifier.dart'; import 'package:paperless_mobile/core/service/connectivity_status_service.dart'; @@ -85,7 +85,6 @@ class _EventListenerShellState extends State if (!currentUser.paperlessUser.canViewInbox || _inboxTimer != null) { return; } - cubit.refreshItemsInInboxCount(false); _inboxTimer = Timer.periodic(30.seconds, (_) { cubit.refreshItemsInInboxCount(false); }); diff --git a/lib/features/similar_documents/cubit/similar_documents_cubit.dart b/lib/features/similar_documents/cubit/similar_documents_cubit.dart index 4ec4653..563aeba 100644 --- a/lib/features/similar_documents/cubit/similar_documents_cubit.dart +++ b/lib/features/similar_documents/cubit/similar_documents_cubit.dart @@ -13,6 +13,7 @@ class SimilarDocumentsCubit extends Cubit final int documentId; @override final ConnectivityStatusService connectivityStatusService; + @override final PaperlessDocumentsApi api; @@ -33,19 +34,9 @@ class SimilarDocumentsCubit extends Cubit onDeleted: remove, onUpdated: replace, ); - _labelRepository.addListener( - this, - onChanged: (labels) { - emit(state.copyWith( - correspondents: labels.correspondents, - documentTypes: labels.documentTypes, - tags: labels.tags, - storagePaths: labels.storagePaths, - )); - }, - ); } + @override Future initialize() async { if (!state.hasLoaded) { await updateFilter( diff --git a/lib/features/similar_documents/cubit/similar_documents_state.dart b/lib/features/similar_documents/cubit/similar_documents_state.dart index 503dbb5..e006fa6 100644 --- a/lib/features/similar_documents/cubit/similar_documents_state.dart +++ b/lib/features/similar_documents/cubit/similar_documents_state.dart @@ -1,20 +1,11 @@ part of 'similar_documents_cubit.dart'; class SimilarDocumentsState extends DocumentPagingState { - final Map correspondents; - final Map documentTypes; - final Map tags; - final Map storagePaths; - const SimilarDocumentsState({ required super.filter, super.hasLoaded, super.isLoading, super.value, - this.correspondents = const {}, - this.documentTypes = const {}, - this.tags = const {}, - this.storagePaths = const {}, }); @override @@ -23,10 +14,6 @@ class SimilarDocumentsState extends DocumentPagingState { hasLoaded, isLoading, value, - correspondents, - documentTypes, - tags, - storagePaths, ]; @override @@ -49,20 +36,12 @@ class SimilarDocumentsState extends DocumentPagingState { bool? isLoading, List>? value, DocumentFilter? filter, - Map? correspondents, - Map? documentTypes, - Map? tags, - Map? storagePaths, }) { return SimilarDocumentsState( hasLoaded: hasLoaded ?? this.hasLoaded, isLoading: isLoading ?? this.isLoading, value: value ?? this.value, filter: filter ?? this.filter, - correspondents: correspondents ?? this.correspondents, - documentTypes: documentTypes ?? this.documentTypes, - tags: tags ?? this.tags, - storagePaths: storagePaths ?? this.storagePaths, ); } } 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/helpers/format_helpers.dart b/lib/helpers/format_helpers.dart index d93ca57..7c52aec 100644 --- a/lib/helpers/format_helpers.dart +++ b/lib/helpers/format_helpers.dart @@ -7,7 +7,7 @@ String formatMaxCount(int? count, [int maxCount = 99]) { return (count ?? 0).toString(); } -String formatBytes(int bytes, int decimals) { +String formatBytes(int bytes, [int decimals = 2]) { if (bytes <= 0) return "0 B"; const suffixes = ["B", "KB", "MB", "GB", "TB", "PB", "EB", "ZB", "YB"]; var i = (log(bytes) / log(1024)).floor(); diff --git a/lib/l10n/intl_ca.arb b/lib/l10n/intl_ca.arb index 2a2c013..f639029 100644 --- a/lib/l10n/intl_ca.arb +++ b/lib/l10n/intl_ca.arb @@ -1001,5 +1001,23 @@ "@discardChangesWarning": { "description": "Warning message shown when the user tries to close a route without saving the changes." }, - "changelog": "Changelog" + "changelog": "Historial de canvis", + "noLogsFoundOn": "Sense logs trovats per {date}.", + "logfileBottomReached": "Final d'aquest arxiu de registres.", + "appLogs": "Logs d'aplicació {date}", + "saveLogsToFile": "Desar registres a arxiu", + "copyToClipboard": "Copia al porta-retalls", + "couldNotLoadLogfileFrom": "No es pot carregar log desde {date}.", + "loadingLogsFrom": "Carregant registres des de {date}...", + "clearLogs": "Netejar registres des de {date}", + "showPdf": "Show PDF", + "@showPdf": { + "description": "Tooltip shown on the \"show pdf\" button on the document edit page" + }, + "hidePdf": "Hide PDF", + "@hidePdf": { + "description": "Tooltip shown on the \"show pdf\" icon button on the document edit page" + }, + "misc": "Miscellaneous", + "loggingOut": "Logging out..." } \ No newline at end of file diff --git a/lib/l10n/intl_cs.arb b/lib/l10n/intl_cs.arb index 3267615..19be896 100644 --- a/lib/l10n/intl_cs.arb +++ b/lib/l10n/intl_cs.arb @@ -1001,5 +1001,23 @@ "@discardChangesWarning": { "description": "Warning message shown when the user tries to close a route without saving the changes." }, - "changelog": "Changelog" + "changelog": "Changelog", + "noLogsFoundOn": "No logs found on {date}.", + "logfileBottomReached": "You have reached the bottom of this logfile.", + "appLogs": "App logs {date}", + "saveLogsToFile": "Save logs to file", + "copyToClipboard": "Copy to clipboard", + "couldNotLoadLogfileFrom": "Could not load logfile from {date}.", + "loadingLogsFrom": "Loading logs from {date}...", + "clearLogs": "Clear logs from {date}", + "showPdf": "Show PDF", + "@showPdf": { + "description": "Tooltip shown on the \"show pdf\" button on the document edit page" + }, + "hidePdf": "Hide PDF", + "@hidePdf": { + "description": "Tooltip shown on the \"show pdf\" icon button on the document edit page" + }, + "misc": "Miscellaneous", + "loggingOut": "Logging out..." } \ No newline at end of file diff --git a/lib/l10n/intl_de.arb b/lib/l10n/intl_de.arb index 6438a81..7b7345b 100644 --- a/lib/l10n/intl_de.arb +++ b/lib/l10n/intl_de.arb @@ -1001,5 +1001,23 @@ "@discardChangesWarning": { "description": "Warning message shown when the user tries to close a route without saving the changes." }, - "changelog": "Changelog" + "changelog": "Changelog", + "noLogsFoundOn": "Keine Logs am {date} gefunden.", + "logfileBottomReached": "Du hast das Ende dieser Logdatei erreicht.", + "appLogs": "App Logs {date}", + "saveLogsToFile": "Logs in Datei speichern", + "copyToClipboard": "In Zwischenablage kopieren", + "couldNotLoadLogfileFrom": "Logs vom {date} konnten nicht geladen werden.", + "loadingLogsFrom": "Lade Logs vom {date}...", + "clearLogs": "Logs vom {date} leeren", + "showPdf": "PDF anzeigen", + "@showPdf": { + "description": "Tooltip shown on the \"show pdf\" button on the document edit page" + }, + "hidePdf": "PDF ausblenden", + "@hidePdf": { + "description": "Tooltip shown on the \"show pdf\" icon button on the document edit page" + }, + "misc": "Sonstige", + "loggingOut": "Abmelden..." } \ No newline at end of file diff --git a/lib/l10n/intl_en.arb b/lib/l10n/intl_en.arb index 37a00a8..3dcfffe 100644 --- a/lib/l10n/intl_en.arb +++ b/lib/l10n/intl_en.arb @@ -1001,5 +1001,23 @@ "@discardChangesWarning": { "description": "Warning message shown when the user tries to close a route without saving the changes." }, - "changelog": "Changelog" + "changelog": "Changelog", + "noLogsFoundOn": "No logs found on {date}.", + "logfileBottomReached": "You have reached the bottom of this logfile.", + "appLogs": "App logs {date}", + "saveLogsToFile": "Save logs to file", + "copyToClipboard": "Copy to clipboard", + "couldNotLoadLogfileFrom": "Could not load logfile from {date}.", + "loadingLogsFrom": "Loading logs from {date}...", + "clearLogs": "Clear logs from {date}", + "showPdf": "Show PDF", + "@showPdf": { + "description": "Tooltip shown on the \"show pdf\" button on the document edit page" + }, + "hidePdf": "Hide PDF", + "@hidePdf": { + "description": "Tooltip shown on the \"show pdf\" icon button on the document edit page" + }, + "misc": "Miscellaneous", + "loggingOut": "Logging out..." } \ No newline at end of file diff --git a/lib/l10n/intl_es.arb b/lib/l10n/intl_es.arb index daa10e5..7e1ce9e 100644 --- a/lib/l10n/intl_es.arb +++ b/lib/l10n/intl_es.arb @@ -984,7 +984,7 @@ "@authenticatingDots": { "description": "Message shown when the app is authenticating the user" }, - "persistingUserInformation": "Manteniendo información del usuario...", + "persistingUserInformation": "Preservando información del usuario...", "fetchingUserInformation": "Obteniendo información del usuario...", "@fetchingUserInformation": { "description": "Message shown when the app loads user data from the server" @@ -993,13 +993,31 @@ "@restoringSession": { "description": "Message shown when the user opens the app and the previous user is tried to be authenticated and logged in" }, - "documentsAssigned": "{count, plural, zero{No documents} one{1 document} other{{count} documents}}", + "documentsAssigned": "{count, plural, zero{Sin documentos} one{1 documento} other{{count} documentos}}", "@documentsAssigned": { "description": "Text shown with a correspondent, document type etc. to indicate the number of documents this filter will maximally yield." }, - "discardChangesWarning": "You have unsaved changes. By continuing, all changes will be lost. Do you want to discard these changes?", + "discardChangesWarning": "Tienes cambios sin guardar. Si continúa, se perderán todos los cambios. ¿Quiere descartar estos cambios?", "@discardChangesWarning": { "description": "Warning message shown when the user tries to close a route without saving the changes." }, - "changelog": "Changelog" + "changelog": "Changelog", + "noLogsFoundOn": "No se encontraron registros en {date}.", + "logfileBottomReached": "Has alcanzado el final del archivo de registro.", + "appLogs": "Registros de la aplicación {date}", + "saveLogsToFile": "Guardar registros en un archivo", + "copyToClipboard": "Copiar al portapapeles", + "couldNotLoadLogfileFrom": "No se pudo cargar el archivo de registro desde {date}.", + "loadingLogsFrom": "Cargando registros desde {date}...", + "clearLogs": "Limpiar registros desde {date}", + "showPdf": "Show PDF", + "@showPdf": { + "description": "Tooltip shown on the \"show pdf\" button on the document edit page" + }, + "hidePdf": "Hide PDF", + "@hidePdf": { + "description": "Tooltip shown on the \"show pdf\" icon button on the document edit page" + }, + "misc": "Miscellaneous", + "loggingOut": "Logging out..." } \ No newline at end of file diff --git a/lib/l10n/intl_fr.arb b/lib/l10n/intl_fr.arb index 8d81cb7..c33ce6b 100644 --- a/lib/l10n/intl_fr.arb +++ b/lib/l10n/intl_fr.arb @@ -1001,5 +1001,23 @@ "@discardChangesWarning": { "description": "Warning message shown when the user tries to close a route without saving the changes." }, - "changelog": "Changelog" + "changelog": "Changelog", + "noLogsFoundOn": "No logs found on {date}.", + "logfileBottomReached": "You have reached the bottom of this logfile.", + "appLogs": "App logs {date}", + "saveLogsToFile": "Save logs to file", + "copyToClipboard": "Copy to clipboard", + "couldNotLoadLogfileFrom": "Could not load logfile from {date}.", + "loadingLogsFrom": "Loading logs from {date}...", + "clearLogs": "Clear logs from {date}", + "showPdf": "Show PDF", + "@showPdf": { + "description": "Tooltip shown on the \"show pdf\" button on the document edit page" + }, + "hidePdf": "Hide PDF", + "@hidePdf": { + "description": "Tooltip shown on the \"show pdf\" icon button on the document edit page" + }, + "misc": "Sonstige", + "loggingOut": "Logging out..." } \ No newline at end of file diff --git a/lib/l10n/intl_pl.arb b/lib/l10n/intl_pl.arb index 4a6fa14..5f1256a 100644 --- a/lib/l10n/intl_pl.arb +++ b/lib/l10n/intl_pl.arb @@ -1001,5 +1001,23 @@ "@discardChangesWarning": { "description": "Warning message shown when the user tries to close a route without saving the changes." }, - "changelog": "Changelog" + "changelog": "Changelog", + "noLogsFoundOn": "No logs found on {date}.", + "logfileBottomReached": "You have reached the bottom of this logfile.", + "appLogs": "App logs {date}", + "saveLogsToFile": "Save logs to file", + "copyToClipboard": "Copy to clipboard", + "couldNotLoadLogfileFrom": "Could not load logfile from {date}.", + "loadingLogsFrom": "Loading logs from {date}...", + "clearLogs": "Clear logs from {date}", + "showPdf": "Show PDF", + "@showPdf": { + "description": "Tooltip shown on the \"show pdf\" button on the document edit page" + }, + "hidePdf": "Hide PDF", + "@hidePdf": { + "description": "Tooltip shown on the \"show pdf\" icon button on the document edit page" + }, + "misc": "Miscellaneous", + "loggingOut": "Logging out..." } \ No newline at end of file diff --git a/lib/l10n/intl_ru.arb b/lib/l10n/intl_ru.arb index 42535d2..a47d43b 100644 --- a/lib/l10n/intl_ru.arb +++ b/lib/l10n/intl_ru.arb @@ -1001,5 +1001,23 @@ "@discardChangesWarning": { "description": "Warning message shown when the user tries to close a route without saving the changes." }, - "changelog": "Changelog" + "changelog": "Changelog", + "noLogsFoundOn": "No logs found on {date}.", + "logfileBottomReached": "You have reached the bottom of this logfile.", + "appLogs": "App logs {date}", + "saveLogsToFile": "Save logs to file", + "copyToClipboard": "Copy to clipboard", + "couldNotLoadLogfileFrom": "Could not load logfile from {date}.", + "loadingLogsFrom": "Loading logs from {date}...", + "clearLogs": "Clear logs from {date}", + "showPdf": "Show PDF", + "@showPdf": { + "description": "Tooltip shown on the \"show pdf\" button on the document edit page" + }, + "hidePdf": "Hide PDF", + "@hidePdf": { + "description": "Tooltip shown on the \"show pdf\" icon button on the document edit page" + }, + "misc": "Miscellaneous", + "loggingOut": "Logging out..." } \ No newline at end of file diff --git a/lib/l10n/intl_tr.arb b/lib/l10n/intl_tr.arb index a1c0e30..2f69024 100644 --- a/lib/l10n/intl_tr.arb +++ b/lib/l10n/intl_tr.arb @@ -1001,5 +1001,23 @@ "@discardChangesWarning": { "description": "Warning message shown when the user tries to close a route without saving the changes." }, - "changelog": "Changelog" + "changelog": "Changelog", + "noLogsFoundOn": "No logs found on {date}.", + "logfileBottomReached": "You have reached the bottom of this logfile.", + "appLogs": "App logs {date}", + "saveLogsToFile": "Save logs to file", + "copyToClipboard": "Copy to clipboard", + "couldNotLoadLogfileFrom": "Could not load logfile from {date}.", + "loadingLogsFrom": "Loading logs from {date}...", + "clearLogs": "Clear logs from {date}", + "showPdf": "Show PDF", + "@showPdf": { + "description": "Tooltip shown on the \"show pdf\" button on the document edit page" + }, + "hidePdf": "Hide PDF", + "@hidePdf": { + "description": "Tooltip shown on the \"show pdf\" icon button on the document edit page" + }, + "misc": "Miscellaneous", + "loggingOut": "Logging out..." } \ No newline at end of file diff --git a/lib/main.dart b/lib/main.dart index ef06c89..ce8b75b 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -16,11 +16,12 @@ import 'package:hydrated_bloc/hydrated_bloc.dart'; import 'package:intl/date_symbol_data_local.dart'; import 'package:intl/intl_standalone.dart'; import 'package:local_auth/local_auth.dart'; +import 'package:logger/logger.dart' as l; import 'package:package_info_plus/package_info_plus.dart'; import 'package:paperless_api/paperless_api.dart'; import 'package:paperless_mobile/constants.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/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/core/database/tables/local_user_app_state.dart'; @@ -28,9 +29,13 @@ import 'package:paperless_mobile/core/exception/server_message_exception.dart'; import 'package:paperless_mobile/core/factory/paperless_api_factory.dart'; import 'package:paperless_mobile/core/factory/paperless_api_factory_impl.dart'; import 'package:paperless_mobile/core/interceptor/language_header.interceptor.dart'; +import 'package:paperless_mobile/features/logging/data/formatted_printer.dart'; +import 'package:paperless_mobile/features/logging/data/logger.dart'; +import 'package:paperless_mobile/features/logging/data/mirrored_file_output.dart'; import 'package:paperless_mobile/core/notifier/document_changed_notifier.dart'; import 'package:paperless_mobile/core/security/session_manager.dart'; import 'package:paperless_mobile/core/service/connectivity_status_service.dart'; +import 'package:paperless_mobile/core/service/file_service.dart'; import 'package:paperless_mobile/features/login/cubit/authentication_cubit.dart'; import 'package:paperless_mobile/features/login/services/authentication_service.dart'; import 'package:paperless_mobile/features/notifications/services/local_notification_service.dart'; @@ -40,11 +45,13 @@ import 'package:paperless_mobile/routes/navigation_keys.dart'; import 'package:paperless_mobile/routes/typed/branches/landing_route.dart'; import 'package:paperless_mobile/routes/typed/shells/authenticated_route.dart'; import 'package:paperless_mobile/routes/typed/top_level/add_account_route.dart'; +import 'package:paperless_mobile/routes/typed/top_level/app_logs_route.dart'; import 'package:paperless_mobile/routes/typed/top_level/changelog_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/theme.dart'; import 'package:path_provider/path_provider.dart'; +import 'package:pretty_dio_logger/pretty_dio_logger.dart'; import 'package:provider/provider.dart'; import 'package:shared_preferences/shared_preferences.dart'; @@ -82,7 +89,11 @@ Future performMigrations() async { final requiresMigrationForCurrentVersion = !performedMigrations.contains(currentVersion); if (requiresMigrationForCurrentVersion) { - debugPrint("Applying migration scripts for version $currentVersion"); + logger.fd( + "Applying migration scripts for version $currentVersion", + className: "", + methodName: "performMigrations", + ); await migrationProcedure(); await sp.setStringList( 'performed_migrations', @@ -91,7 +102,6 @@ Future performMigrations() async { } } - Future _initHive() async { await Hive.initFlutter(); @@ -113,7 +123,16 @@ Future _initHive() async { void main() async { runZonedGuarded(() async { WidgetsFlutterBinding.ensureInitialized(); + await FileService.instance.initialize(); + + logger = l.Logger( + output: MirroredFileOutput(), + printer: FormattedPrinter(), + level: l.Level.trace, + filter: l.ProductionFilter(), + ); Paint.enableDithering = true; + // if (kDebugMode) { // // URL: http://localhost:3131 // // Login: admin:test @@ -125,6 +144,7 @@ void main() async { // ) // .start(); // } + packageInfo = await PackageInfo.fromPlatform(); if (Platform.isAndroid) { @@ -159,6 +179,15 @@ void main() async { ); // Manages security context, required for self signed client certificates final sessionManager = SessionManager([ + PrettyDioLogger( + compact: true, + responseBody: false, + responseHeader: false, + request: false, + requestBody: false, + requestHeader: false, + logPrint: (object) => logger.t, + ), languageHeaderInterceptor, ]); @@ -207,16 +236,25 @@ void main() async { ), ), ); - }, (error, stack) { + }, (error, stackTrace) { + if (error is StateError && + error.message.contains("Cannot emit new states")) { + { + return; + } + } // Catches all unexpected/uncaught errors and prints them to the console. - String message = switch (error) { + final message = switch (error) { PaperlessApiException e => e.details ?? error.toString(), ServerMessageException e => e.message, - _ => error.toString() + _ => null }; - debugPrint("An unepxected exception has occured!"); - debugPrint(message); - debugPrintStack(stackTrace: stack); + logger.fe( + "An unexpected error occurred${message != null ? "- $message" : ""}", + error: message == null ? error : null, + methodName: "main", + stackTrace: stackTrace, + ); }); } @@ -254,7 +292,7 @@ class _GoRouterShellState extends State { final DisplayMode mostOptimalMode = sameResolution.isNotEmpty ? sameResolution.first : active; - debugPrint('Setting refresh rate to ${mostOptimalMode.refreshRate}'); + logger.fi('Setting refresh rate to ${mostOptimalMode.refreshRate}'); await FlutterDisplayMode.setPreferredMode(mostOptimalMode); } @@ -301,12 +339,6 @@ class _GoRouterShellState extends State { if (context.canPop()) { context.pop(); } - // LoginRoute( - // $extra: errorState.clientCertificate, - // password: errorState.password, - // serverUrl: errorState.serverUrl, - // username: errorState.username, - // ).go(context); break; } }, @@ -320,6 +352,7 @@ class _GoRouterShellState extends State { $loggingOutRoute, $addAccountRoute, $changelogRoute, + $appLogsRoute, $authenticatedRoute, ], ), 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 b1a2ebc..3762cbe 100644 --- a/lib/routes/typed/shells/authenticated_route.dart +++ b/lib/routes/typed/shells/authenticated_route.dart @@ -5,7 +5,7 @@ import 'package:flutter/widgets.dart'; import 'package:go_router/go_router.dart'; import 'package:hive_flutter/adapters.dart'; import 'package:paperless_api/paperless_api.dart'; -import 'package:paperless_mobile/core/config/hive/hive_config.dart'; +import 'package:paperless_mobile/core/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/core/factory/paperless_api_factory.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/lib/routes/typed/shells/scaffold_shell_route.dart b/lib/routes/typed/shells/scaffold_shell_route.dart index abf16bd..c85f9c9 100644 --- a/lib/routes/typed/shells/scaffold_shell_route.dart +++ b/lib/routes/typed/shells/scaffold_shell_route.dart @@ -1,7 +1,7 @@ import 'package:flutter/widgets.dart'; import 'package:go_router/go_router.dart'; import 'package:hive/hive.dart'; -import 'package:paperless_mobile/core/config/hive/hive_config.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/home/view/scaffold_with_navigation_bar.dart'; diff --git a/lib/routes/typed/top_level/app_logs_route.dart b/lib/routes/typed/top_level/app_logs_route.dart new file mode 100644 index 0000000..75121ad --- /dev/null +++ b/lib/routes/typed/top_level/app_logs_route.dart @@ -0,0 +1,28 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:go_router/go_router.dart'; +import 'package:paperless_mobile/features/logging/cubit/app_logs_cubit.dart'; +import 'package:paperless_mobile/features/logging/view/app_logs_page.dart'; +import 'package:paperless_mobile/routes/navigation_keys.dart'; +import 'package:paperless_mobile/theme.dart'; + +part 'app_logs_route.g.dart'; + +@TypedGoRoute(path: '/app-logs') +class AppLogsRoute extends GoRouteData { + static final $parentNavigatorKey = rootNavigatorKey; + + @override + Widget build(BuildContext context, GoRouterState state) { + return AnnotatedRegion( + value: buildOverlayStyle(Theme.of(context)), + child: BlocProvider( + create: (context) => AppLogsCubit( + DateTime.now(), + context.read(), + )..loadLogs(DateTime.now()), + child: AppLogsPage(key: state.pageKey), + ), + ); + } +} diff --git a/lib/routes/typed/top_level/changelog_route.dart b/lib/routes/typed/top_level/changelog_route.dart index a2acbba..ad56cf5 100644 --- a/lib/routes/typed/top_level/changelog_route.dart +++ b/lib/routes/typed/top_level/changelog_route.dart @@ -6,7 +6,7 @@ import 'package:paperless_mobile/routes/utils/dialog_page.dart'; part 'changelog_route.g.dart'; -@TypedGoRoute(path: '/changelogs)') +@TypedGoRoute(path: '/changelogs') class ChangelogRoute extends GoRouteData { static final $parentNavigatorKey = rootNavigatorKey; @override diff --git a/lib/routes/typed/top_level/logging_out_route.dart b/lib/routes/typed/top_level/logging_out_route.dart index 55378f6..d2151cd 100644 --- a/lib/routes/typed/top_level/logging_out_route.dart +++ b/lib/routes/typed/top_level/logging_out_route.dart @@ -1,5 +1,6 @@ import 'package:flutter/material.dart'; import 'package:go_router/go_router.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'; @@ -15,10 +16,10 @@ class LoggingOutRoute extends GoRouteData { @override Page buildPage(BuildContext context, GoRouterState state) { - return const NoTransitionPage( + return NoTransitionPage( child: Scaffold( body: Center( - child: Text("Logging out..."), //TODO: INTL + child: Text(S.of(context)!.loggingOut), ), ), ); diff --git a/lib/routes/typed/top_level/login_route.dart b/lib/routes/typed/top_level/login_route.dart index 6e05db5..5da96da 100644 --- a/lib/routes/typed/top_level/login_route.dart +++ b/lib/routes/typed/top_level/login_route.dart @@ -4,7 +4,7 @@ import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:go_router/go_router.dart'; import 'package:hive_flutter/adapters.dart'; -import 'package:paperless_mobile/core/config/hive/hive_extensions.dart'; +import 'package:paperless_mobile/core/database/hive/hive_extensions.dart'; import 'package:paperless_mobile/features/login/cubit/authentication_cubit.dart'; import 'package:paperless_mobile/features/login/model/client_certificate.dart'; import 'package:paperless_mobile/features/login/view/login_page.dart'; diff --git a/packages/paperless_api/lib/src/converters/local_date_time_json_converter.dart b/packages/paperless_api/lib/src/converters/local_date_time_json_converter.dart index 71b9060..9356998 100644 --- a/packages/paperless_api/lib/src/converters/local_date_time_json_converter.dart +++ b/packages/paperless_api/lib/src/converters/local_date_time_json_converter.dart @@ -1,4 +1,3 @@ - import 'package:json_annotation/json_annotation.dart'; class LocalDateTimeJsonConverter extends JsonConverter { @@ -11,6 +10,6 @@ class LocalDateTimeJsonConverter extends JsonConverter { @override String toJson(DateTime object) { - return object.toIso8601String(); + return object.toUtc().toIso8601String(); } } 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( diff --git a/pubspec.lock b/pubspec.lock index 8585aac..cf28eae 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -394,6 +394,14 @@ packages: url: "https://pub.dev" source: hosted version: "2.0.5" + extended_masked_text: + dependency: "direct main" + description: + name: extended_masked_text + sha256: dba132fffa2b931e8cdd005e0509dfac359d3f98a175eca18c0ac71605247b6b + url: "https://pub.dev" + source: hosted + version: "2.3.1" fake_async: dependency: transitive description: @@ -997,6 +1005,14 @@ packages: url: "https://pub.dev" source: hosted version: "1.0.10" + logger: + dependency: "direct main" + description: + name: logger + sha256: "6bbb9d6f7056729537a4309bda2e74e18e5d9f14302489cc1e93f33b3fe32cac" + url: "https://pub.dev" + source: hosted + version: "2.0.2+1" logging: dependency: transitive description: @@ -1215,18 +1231,18 @@ packages: dependency: "direct main" description: name: permission_handler - sha256: bc56bfe9d3f44c3c612d8d393bd9b174eb796d706759f9b495ac254e4294baa5 + sha256: "284a66179cabdf942f838543e10413246f06424d960c92ba95c84439154fcac8" url: "https://pub.dev" source: hosted - version: "10.4.5" + version: "11.0.1" permission_handler_android: dependency: transitive description: name: permission_handler_android - sha256: "59c6322171c29df93a22d150ad95f3aa19ed86542eaec409ab2691b8f35f9a47" + sha256: ace7d15a3d1a4a0b91c041d01e5405df221edb9de9116525efc773c74e6fc790 url: "https://pub.dev" source: hosted - version: "10.3.6" + version: "11.0.5" permission_handler_apple: dependency: transitive description: @@ -1649,7 +1665,7 @@ packages: source: hosted version: "0.3.1" synchronized: - dependency: transitive + dependency: "direct main" description: name: synchronized sha256: "5fcbd27688af6082f5abd611af56ee575342c30e87541d0245f7ff99faa02c60" diff --git a/pubspec.yaml b/pubspec.yaml index 561da11..28cd598 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -15,7 +15,7 @@ publish_to: "none" # Remove this line if you wish to publish to pub.dev # In iOS, build-name is used as CFBundleShortVersionString while build-number used as CFBundleVersion. # Read more about iOS versioning at # https://developer.apple.com/library/archive/documentation/General/Reference/InfoPlistKeyReference/Articles/CoreFoundationKeys.html -version: 3.0.6+53 +version: 3.1.0+54 environment: sdk: ">=3.0.0 <4.0.0" @@ -31,7 +31,7 @@ dependencies: sdk: flutter flutter_localizations: sdk: flutter - permission_handler: ^10.2.0 + permission_handler: ^11.0.1 pdf: ^3.10.2 edge_detection: git: @@ -98,6 +98,9 @@ dependencies: flutter_animate: ^4.2.0+1 shared_preferences: ^2.2.1 flutter_markdown: ^0.6.18 + logger: ^2.0.2+1 + synchronized: ^3.1.0 + extended_masked_text: ^2.3.1 # camerawesome: ^2.0.0-dev.1 dependency_overrides: @@ -152,33 +155,15 @@ flutter: - test/fixtures/document_types/ - assets/changelogs/ - # An image asset can refer to one or more resolution-specific "variants", see - # https://flutter.dev/assets-and-images/#resolution-aware. - # For details regarding adding assets from package dependencies, see - # https://flutter.dev/assets-and-images/#from-packages - # To add custom fonts to your application, add a fonts section here, - # in this "flutter" section. Each entry in this list should have a - # "family" key with the font family name, and a "fonts" key with a - # list giving the asset and other descriptors for the font. For - # example: - # fonts: - # - family: Schyler - # fonts: - # - asset: fonts/Schyler-Regular.ttf - # - asset: fonts/Schyler-Italic.ttf - # style: italic - # - family: Trajan Pro - # fonts: - # - asset: fonts/TrajanPro.ttf - # - asset: fonts/TrajanPro_Bold.ttf - # weight: 700 - # - # For details regarding fonts from package dependencies, - # see https://flutter.dev/custom-fonts/#from-packages + fonts: + - family: RobotoMono + fonts: + - asset: assets/fonts/RobotoMono-Regular.ttf + + flutter_native_splash: image: assets/logos/paperless_logo_green.png color: "#f9f9f9" - image_dark: assets/logos/paperless_logo_white.png color_dark: "#181818"