From f0c3ced804bada518abc99d381a50dfb083214db Mon Sep 17 00:00:00 2001 From: Anton Stubenbord Date: Wed, 11 Oct 2023 19:09:26 +0200 Subject: [PATCH 1/6] feat: add file logs and logging view --- lib/core/logging/logger.dart | 103 +++++ lib/core/logging/view/app_logs_page.dart | 360 ++++++++++++++++++ .../notifier/document_changed_notifier.dart | 3 - lib/core/repository/label_repository.dart | 11 +- lib/core/security/session_manager.dart | 8 - lib/core/service/file_service.dart | 43 ++- lib/features/app_drawer/view/app_drawer.dart | 12 + .../cubit/document_details_cubit.dart | 3 +- .../cubit/document_scanner_cubit.dart | 5 +- .../document_upload_preparation_page.dart | 14 +- .../documents/cubit/documents_cubit.dart | 4 - lib/features/inbox/cubit/inbox_cubit.dart | 10 +- .../labels/view/pages/labels_page.dart | 10 +- lib/features/landing/view/landing_page.dart | 6 - .../login/cubit/authentication_cubit.dart | 281 +++++--------- lib/helpers/format_helpers.dart | 2 +- lib/main.dart | 28 +- pubspec.lock | 8 + pubspec.yaml | 1 + 19 files changed, 681 insertions(+), 231 deletions(-) create mode 100644 lib/core/logging/logger.dart create mode 100644 lib/core/logging/view/app_logs_page.dart diff --git a/lib/core/logging/logger.dart b/lib/core/logging/logger.dart new file mode 100644 index 0000000..1a5fbe4 --- /dev/null +++ b/lib/core/logging/logger.dart @@ -0,0 +1,103 @@ +import 'dart:async'; +import 'dart:convert'; +import 'dart:io'; + +import 'package:collection/collection.dart'; +import 'package:flutter/widgets.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:rxdart/rxdart.dart'; + +late Logger logger; + +class MirroredFileOutput extends LogOutput { + late final File file; + final Completer _initCompleter = Completer(); + MirroredFileOutput(); + + @override + Future init() async { + final today = DateFormat("yyyy-MM-dd").format(DateTime.now()); + final logDir = await FileService.logDirectory; + file = File(p.join(logDir.path, '$today.log')); + debugPrint("Logging files to ${file.path}."); + _initCompleter.complete(); + try { + final oldLogs = await logDir.list().whereType().toList(); + 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 { + for (var line in event.lines) { + debugPrint(line); + if (_initCompleter.isCompleted) { + await file.writeAsString( + "$line\n", + mode: FileMode.append, + ); + } + } + } +} + +class SpringBootLikePrinter extends LogPrinter { + SpringBootLikePrinter(); + static final _timestampFormat = DateFormat("yyyy-MM-dd HH:mm:ss.SSS"); + + @override + List log(LogEvent event) { + final level = _buildLeftAligned(event.level.name.toUpperCase(), + Level.values.map((e) => e.name.length).max); + String message = _stringifyMessage(event.message); + final timestamp = + _buildLeftAligned(_timestampFormat.format(event.time), 23); + final traceRegex = RegExp(r"(.*)#(.*)\(\): (.*)"); + final match = traceRegex.firstMatch(message); + if (match != null) { + final className = match.group(1)!; + final methodName = match.group(2)!; + final remainingMessage = match.group(3)!; + final formattedClassName = _buildRightAligned(className, 25); + final formattedMethodName = _buildLeftAligned(methodName, 25); + message = message.replaceFirst(traceRegex, + "[$formattedClassName] - $formattedMethodName: $remainingMessage"); + } else { + message = List.filled(55, " ").join("") + ": " + message; + } + return [ + '$timestamp\t$level --- $message', + if (event.error != null) '\t\t${event.error}', + if (event.stackTrace != null) '\t\t${event.stackTrace.toString()}', + ]; + } + + String _buildLeftAligned(String message, int maxLength) { + return message.padRight(maxLength, ' '); + } + + String _buildRightAligned(String message, int maxLength) { + return message.padLeft(maxLength, ' '); + } + + String _stringifyMessage(dynamic message) { + final finalMessage = message is Function ? message() : message; + if (finalMessage is Map || finalMessage is Iterable) { + var encoder = const JsonEncoder.withIndent(null); + return encoder.convert(finalMessage); + } else { + return finalMessage.toString(); + } + } +} diff --git a/lib/core/logging/view/app_logs_page.dart b/lib/core/logging/view/app_logs_page.dart new file mode 100644 index 0000000..3f127b9 --- /dev/null +++ b/lib/core/logging/view/app_logs_page.dart @@ -0,0 +1,360 @@ +import 'dart:async'; +import 'dart:io'; + +import 'package:file_picker/file_picker.dart'; +import 'package:flutter/cupertino.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_animate/flutter_animate.dart'; +import 'package:intl/intl.dart'; +import 'package:logger/logger.dart'; +import 'package:paperless_mobile/core/service/file_service.dart'; +import 'package:paperless_mobile/extensions/flutter_extensions.dart'; +import 'package:path/path.dart' as p; +import 'package:rxdart/subjects.dart'; + +final _fileNameFormat = DateFormat("yyyy-MM-dd"); + +class AppLogsPage extends StatefulWidget { + const AppLogsPage({super.key}); + + @override + State createState() => _AppLogsPageState(); +} + +class _AppLogsPageState extends State { + final _fileContentStream = BehaviorSubject(); + final ScrollController _scrollController = ScrollController(); + + StreamSubscription? _fileChangesSubscription; + + late DateTime _date; + File? file; + bool autoScroll = true; + List? _availableLogs; + + Future _initFile() async { + final logDir = await FileService.logDirectory; + // logDir.listSync().whereType().forEach((element) { + // element.deleteSync(); + // }); + if (logDir.listSync().isEmpty) { + return; + } + + WidgetsBinding.instance.addPostFrameCallback((timeStamp) async { + final filename = _fileNameFormat.format(_date); + setState(() { + file = File(p.join(logDir.path, '$filename.log')); + }); + _scrollController.addListener(_initialScrollListener); + _updateFileContent(); + _fileChangesSubscription?.cancel(); + _fileChangesSubscription = file!.watch().listen((event) async { + await _updateFileContent(); + }); + }); + } + + void _initialScrollListener() { + if (_scrollController.positions.isNotEmpty) { + _scrollController.animateTo( + 0, + duration: 500.milliseconds, + curve: Curves.easeIn, + ); + _scrollController.removeListener(_initialScrollListener); + } + } + + @override + void initState() { + super.initState(); + _date = DateTime.now().copyWith( + minute: 0, + hour: 0, + second: 0, + millisecond: 0, + microsecond: 0, + ); + _initFile(); + () async { + final logDir = await FileService.logDirectory; + final files = logDir.listSync(followLinks: false).whereType(); + final fileNames = files.map((e) => p.basenameWithoutExtension(e.path)); + final dates = + fileNames.map((filename) => _fileNameFormat.parseStrict(filename)); + _availableLogs = dates.toList(); + }(); + } + + @override + void dispose() { + _fileChangesSubscription?.cancel(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + final locale = Localizations.localeOf(context).toString(); + final theme = Theme.of(context); + return Scaffold( + appBar: AppBar( + title: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Text("Logs"), + SizedBox(width: 16), + DropdownButton( + + value: _date, + items: [ + for (var date in _availableLogs ?? []) + DropdownMenuItem( + child: Text(DateFormat.yMMMd(locale).format(date)), + value: date, + ), + ], + onChanged: (value) { + if (value != null) { + setState(() { + _date = value; + }); + _initFile(); + } + }, + ), + ], + ), + actions: file != null + ? [ + IconButton( + tooltip: "Save log file to selected directory", + onPressed: () => _saveFile(locale), + icon: const Icon(Icons.download), + ), + IconButton( + tooltip: "Copy logs to clipboard", + onPressed: _copyToClipboard, + icon: const Icon(Icons.copy), + ).padded(), + ] + : null, + ), + body: Builder( + builder: (context) { + if (_availableLogs == null) { + return Center( + child: Text("No logs available."), + ); + } + return StreamBuilder( + stream: _fileContentStream, + builder: (context, snapshot) { + if (!snapshot.hasData || file == null) { + return const Center( + child: Text( + "Initializing logs...", + ), + ); + } + final messages = _transformLog(snapshot.data!).reversed.toList(); + return ColoredBox( + color: theme.colorScheme.background, + child: Column( + children: [ + Expanded( + child: ListView.builder( + reverse: true, + controller: _scrollController, + itemBuilder: (context, index) { + if (index == 0) { + return Center( + child: Text( + "End of logs.", + style: theme.textTheme.labelLarge?.copyWith( + fontStyle: FontStyle.italic, + ), + ), + ).padded(24); + } + final logMessage = messages[index - 1]; + final altColor = CupertinoDynamicColor.withBrightness( + color: Colors.grey.shade200, + darkColor: Colors.grey.shade800, + ).resolveFrom(context); + return _LogMessageWidget( + message: logMessage, + backgroundColor: (index % 2 == 0) + ? theme.colorScheme.background + : altColor, + ); + }, + itemCount: messages.length + 1, + ), + ), + ], + ), + ); + }, + ); + }, + ), + ); + } + + Future _saveFile(String locale) async { + assert(file != null); + 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 ? "/storage/emulated/0/Download/" : null, + ); + if (parentDir != null) { + await file!.copy(p.join(parentDir, filename)); + } + } + + Future _copyToClipboard() async { + assert(file != null); + final content = await file!.readAsString(); + await Clipboard.setData(ClipboardData(text: content)); + } + + List<_LogMessage> _transformLog(String log) { + List<_LogMessage> messages = []; + List currentCoherentLines = []; + final lines = log.split("\n"); + for (var line in lines) { + final isMatch = _LogMessage.hasMatch(line); + if (currentCoherentLines.isNotEmpty && isMatch) { + messages.add(_LogMessage(message: currentCoherentLines.join("\n"))); + currentCoherentLines.clear(); + messages.add(_LogMessage.fromMessage(line)); + } + if (_LogMessage.hasMatch(line)) { + messages.add(_LogMessage.fromMessage(line)); + } else { + currentCoherentLines.add(line); + } + } + + return messages; + } + + Future _updateFileContent() async { + final content = await file!.readAsString(); + _fileContentStream.add(content); + Future.delayed(400.milliseconds, () { + _scrollController.animateTo( + 0, + duration: 500.milliseconds, + curve: Curves.easeIn, + ); + }); + } +} + +class _LogMessage { + 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; + + bool get isFormatted => level != null; + const _LogMessage({ + this.level, + required this.message, + this.className, + this.methodName, + this.timestamp, + }); + + static bool hasMatch(String message) => pattern.hasMatch(message); + + factory _LogMessage.fromMessage(String message) { + final match = pattern.firstMatch(message); + if (match == null) { + return _LogMessage(message: message); + } + return _LogMessage( + level: Level.values.byName(match.namedGroup('level')!.toLowerCase()), + message: match.namedGroup('message')!, + className: match.namedGroup('className'), + methodName: match.namedGroup('methodName'), + timestamp: DateTime.tryParse(match.namedGroup('timestamp') ?? ''), + ); + } +} + +class _LogMessageWidget extends StatelessWidget { + final _LogMessage message; + final Color backgroundColor; + const _LogMessageWidget({ + required this.message, + required this.backgroundColor, + }); + + @override + Widget build(BuildContext context) { + final c = Theme.of(context).colorScheme; + if (!message.isFormatted) { + return Text( + message.message, + style: Theme.of(context).textTheme.bodySmall?.copyWith( + fontSize: 5, + color: c.onBackground.withOpacity(0.7), + ), + ); + } + 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, + _ => c.onBackground, + }; + 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, + }; + return Material( + child: ListTile( + trailing: Icon( + icon, + color: color, + ), + tileColor: backgroundColor, + title: Text( + message.message, + style: TextStyle(color: color), + ), + subtitle: message.className != null + ? Text( + "${message.className ?? ''} ${message.methodName ?? ''}", + style: TextStyle( + color: color.withOpacity(0.75), + fontSize: 10, + fontFamily: "monospace", + ), + ) + : null, + leading: message.timestamp != null + ? Text(DateFormat("HH:mm:ss.SSS").format(message.timestamp!)) + : null, + ), + ); + } +} diff --git a/lib/core/notifier/document_changed_notifier.dart b/lib/core/notifier/document_changed_notifier.dart index e1c2bba..f992ca3 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); } diff --git a/lib/core/repository/label_repository.dart b/lib/core/repository/label_repository.dart index e7b73dc..4e068df 100644 --- a/lib/core/repository/label_repository.dart +++ b/lib/core/repository/label_repository.dart @@ -11,19 +11,14 @@ 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); - } + } Future createTag(Tag object) async { @@ -95,9 +90,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..81c53b7 100644 --- a/lib/core/service/file_service.dart +++ b/lib/core/service/file_service.dart @@ -1,6 +1,8 @@ import 'dart:io'; import 'package:flutter/foundation.dart'; +import 'package:paperless_mobile/core/logging/logger.dart'; +import 'package:paperless_mobile/helpers/format_helpers.dart'; import 'package:path_provider/path_provider.dart'; import 'package:rxdart/rxdart.dart'; import 'package:uuid/uuid.dart'; @@ -54,9 +56,24 @@ class FileService { } } + static Future get logDirectory async { + if (Platform.isAndroid) { + return getExternalStorageDirectories(type: StorageDirectory.documents) + .then((directory) async => + directory?.firstOrNull ?? + await getApplicationDocumentsDirectory()) + .then((directory) => + Directory('${directory.path}/logs').create(recursive: true)); + } else if (Platform.isIOS) { + return getApplicationDocumentsDirectory().then( + (value) => Directory('${value.path}/logs').create(recursive: true)); + } + throw UnsupportedError("Platform not supported."); + } + static Future get downloadsDirectory async { if (Platform.isAndroid) { - Directory directory = Directory('/storage/emulated/0/Download'); + var directory = Directory('/storage/emulated/0/Download'); if (!directory.existsSync()) { final downloadsDir = await getExternalStorageDirectories( type: StorageDirectory.downloads, @@ -93,12 +110,30 @@ class FileService { } static Future clearUserData({required String userId}) async { + logger.t("FileService#clearUserData(): Clearing data for user $userId..."); + final scanDir = await temporaryScansDirectory; + final scanDirSize = formatBytes(await getDirSizeInBytes(scanDir)); final tempDir = await temporaryDirectory; + final tempDirSize = formatBytes(await getDirSizeInBytes(tempDir)); final consumptionDir = await getConsumptionDirectory(userId: userId); + final consumptionDirSize = + formatBytes(await getDirSizeInBytes(consumptionDir)); + + logger.t("FileService#clearUserData(): Removing scans..."); await scanDir.delete(recursive: true); + logger.t("FileService#clearUserData(): Removed $scanDirSize..."); + + logger.t( + "FileService#clearUserData(): Removing temporary files and cache content..."); + await tempDir.delete(recursive: true); + logger.t("FileService#clearUserData(): Removed $tempDirSize..."); + + logger.t( + "FileService#clearUserData(): Removing files waiting for consumption..."); await consumptionDir.delete(recursive: true); + logger.t("FileService#clearUserData(): Removed $consumptionDirSize..."); } static Future clearDirectoryContent(PaperlessDirectoryType type) async { @@ -120,6 +155,12 @@ class FileService { static Future> getAllSubdirectories(Directory directory) { return directory.list().whereType().toList(); } + + static Future getDirSizeInBytes(Directory dir) async { + return dir + .list(recursive: true) + .fold(0, (previous, element) => previous + element.statSync().size); + } } enum PaperlessDirectoryType { diff --git a/lib/features/app_drawer/view/app_drawer.dart b/lib/features/app_drawer/view/app_drawer.dart index ad7bed7..9144dec 100644 --- a/lib/features/app_drawer/view/app_drawer.dart +++ b/lib/features/app_drawer/view/app_drawer.dart @@ -6,6 +6,7 @@ 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/logging/view/app_logs_page.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'; @@ -181,6 +182,17 @@ class AppDrawer extends StatelessWidget { .fade(duration: 1.seconds, begin: 1, end: 0.3); }, ), + ListTile( + dense: true, + leading: const Icon(Icons.subject), + title: const Text('Logs'), //TODO: INTL + onTap: () { + Navigator.of(context) + .push(MaterialPageRoute(builder: (context) { + return const AppLogsPage(); + })); + }, + ), ListTile( dense: true, leading: const Icon(Icons.settings_outlined), diff --git a/lib/features/document_details/cubit/document_details_cubit.dart b/lib/features/document_details/cubit/document_details_cubit.dart index 30f0eba..e5c10cb 100644 --- a/lib/features/document_details/cubit/document_details_cubit.dart +++ b/lib/features/document_details/cubit/document_details_cubit.dart @@ -6,6 +6,7 @@ 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/logging/logger.dart'; import 'package:paperless_mobile/core/notifier/document_changed_notifier.dart'; import 'package:paperless_mobile/core/repository/label_repository.dart'; import 'package:paperless_mobile/core/service/file_service.dart'; @@ -169,7 +170,7 @@ class DocumentDetailsCubit extends Cubit { locale: locale, userId: userId, ); - debugPrint("Downloaded file to $targetPath"); + logger.i("Document '${state.document.title}' saved to $targetPath."); } Future shareDocument({bool shareOriginal = false}) async { diff --git a/lib/features/document_scan/cubit/document_scanner_cubit.dart b/lib/features/document_scan/cubit/document_scanner_cubit.dart index de54d25..9471dc0 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/core/logging/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,13 @@ class DocumentScannerCubit extends Cubit { : super(const InitialDocumentScannerState()); Future initialize() async { - debugPrint("Restoring scans..."); + logger.t("Restoring scans..."); emit(const RestoringDocumentScannerState()); final tempDir = await FileService.temporaryScansDirectory; final allFiles = tempDir.list().whereType(); final scans = await allFiles.where((event) => event.path.endsWith(".jpeg")).toList(); - debugPrint("Restored ${scans.length} scans."); + logger.t("Restored ${scans.length} scans."); emit( scans.isEmpty ? const InitialDocumentScannerState() 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..b120fd9 100644 --- a/lib/features/document_upload/view/document_upload_preparation_page.dart +++ b/lib/features/document_upload/view/document_upload_preparation_page.dart @@ -12,6 +12,7 @@ import 'package:paperless_api/paperless_api.dart'; import 'package:paperless_mobile/core/config/hive/hive_config.dart'; import 'package:paperless_mobile/core/database/tables/global_settings.dart'; import 'package:paperless_mobile/core/database/tables/local_user_account.dart'; +import 'package:paperless_mobile/core/logging/logger.dart'; import 'package:paperless_mobile/core/repository/label_repository.dart'; import 'package:paperless_mobile/core/widgets/future_or_builder.dart'; import 'package:paperless_mobile/extensions/flutter_extensions.dart'; @@ -376,10 +377,17 @@ class _DocumentUploadPreparationPageState showErrorMessage(context, error, stackTrace); } on PaperlessFormValidationException catch (exception) { setState(() => _errors = exception.validationMessages); - } catch (unknownError, stackTrace) { - debugPrint(unknownError.toString()); + } catch (error, stackTrace) { + logger.e( + "An unknown error occurred during document upload.", + error: error, + stackTrace: stackTrace, + ); showErrorMessage( - context, const PaperlessApiException.unknown(), stackTrace); + context, + const PaperlessApiException.unknown(), + stackTrace, + ); } finally { setState(() { _isUploadLoading = false; diff --git a/lib/features/documents/cubit/documents_cubit.dart b/lib/features/documents/cubit/documents_cubit.dart index b5fb1a8..de482c9 100644 --- a/lib/features/documents/cubit/documents_cubit.dart +++ b/lib/features/documents/cubit/documents_cubit.dart @@ -74,7 +74,6 @@ class DocumentsCubit extends Cubit } Future bulkDelete(List documents) async { - debugPrint("[DocumentsCubit] bulkRemove"); await api.bulkAction( BulkDeleteAction(documents.map((doc) => doc.id)), ); @@ -85,7 +84,6 @@ class DocumentsCubit extends Cubit } void toggleDocumentSelection(DocumentModel model) { - debugPrint("[DocumentsCubit] toggleSelection"); if (state.selectedIds.contains(model.id)) { emit( state.copyWith( @@ -100,12 +98,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/inbox/cubit/inbox_cubit.dart b/lib/features/inbox/cubit/inbox_cubit.dart index 7ab9bb8..c4c3430 100644 --- a/lib/features/inbox/cubit/inbox_cubit.dart +++ b/lib/features/inbox/cubit/inbox_cubit.dart @@ -4,6 +4,7 @@ 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/core/logging/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'; @@ -83,11 +84,17 @@ class InboxCubit extends HydratedCubit } Future refreshItemsInInboxCount([bool shouldLoadInbox = true]) async { - debugPrint("Checking for new items in inbox..."); + logger.t( + "InboxCubit#refreshItemsInInboxCount(): Checking for new documents in inbox..."); final stats = await _statsApi.getServerStatistics(); if (stats.documentsInInbox != state.itemsInInboxCount && shouldLoadInbox) { + logger.t( + "InboxCubit#refreshItemsInInboxCount(): New documents found in inbox, reloading inbox."); await loadInbox(); + } else { + logger.t( + "InboxCubit#refreshItemsInInboxCount(): No new documents found in inbox."); } emit(state.copyWith(itemsInInboxCount: stats.documentsInInbox)); } @@ -97,7 +104,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/labels/view/pages/labels_page.dart b/lib/features/labels/view/pages/labels_page.dart index fe2a6a7..12368d2 100644 --- a/lib/features/labels/view/pages/labels_page.dart +++ b/lib/features/labels/view/pages/labels_page.dart @@ -7,6 +7,7 @@ import 'package:paperless_mobile/core/config/hive/hive_config.dart'; import 'package:paperless_mobile/core/database/tables/global_settings.dart'; import 'package:paperless_mobile/core/database/tables/local_user_account.dart'; import 'package:paperless_mobile/core/delegate/customizable_sliver_persistent_header_delegate.dart'; +import 'package:paperless_mobile/core/logging/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,16 @@ class _LabelsPageState extends State ][_currentIndex] .call(); } catch (error, stackTrace) { - debugPrint( - "[LabelsPage] RefreshIndicator.onRefresh " + logger.e( + "An error ocurred while reloading " "${[ "correspondents", "document types", "tags", "storage paths" - ][_currentIndex]}: " - "An error occurred (${error.toString()})", + ][_currentIndex]}: ${error.toString()}", + stackTrace: stackTrace, ); - debugPrintStack(stackTrace: stackTrace); } }, child: TabBarView( diff --git a/lib/features/landing/view/landing_page.dart b/lib/features/landing/view/landing_page.dart index 78c7deb..08d31b1 100644 --- a/lib/features/landing/view/landing_page.dart +++ b/lib/features/landing/view/landing_page.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/login/cubit/authentication_cubit.dart b/lib/features/login/cubit/authentication_cubit.dart index d4ba6e2..2631069 100644 --- a/lib/features/login/cubit/authentication_cubit.dart +++ b/lib/features/login/cubit/authentication_cubit.dart @@ -13,6 +13,7 @@ import 'package:paperless_mobile/core/database/tables/local_user_settings.dart'; import 'package:paperless_mobile/core/database/tables/user_credentials.dart'; import 'package:paperless_mobile/core/factory/paperless_api_factory.dart'; import 'package:paperless_mobile/core/interceptor/language_header.interceptor.dart'; +import 'package:paperless_mobile/core/logging/logger.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,10 +56,7 @@ class AuthenticationCubit extends Cubit { } emit(const AuthenticatingState(AuthenticatingStage.authenticating)); final localUserId = "${credentials.username}@$serverUrl"; - _debugPrintMessage( - "login", - "Trying to login $localUserId...", - ); + logger.t("AuthenticationCubit#login(): Trying to log in $localUserId..."); try { await _addUser( localUserId, @@ -97,35 +95,26 @@ class AuthenticationCubit extends Cubit { await globalSettings.save(); emit(AuthenticatedState(localUserId: localUserId)); - _debugPrintMessage( - "login", - "User successfully logged in.", - ); + logger.t( + 'AuthenticationCubit#login(): User $localUserId successfully logged in.'); } /// Switches to another account if it exists. Future switchAccount(String localUserId) async { emit(const SwitchingAccountsState()); - _debugPrintMessage( - "switchAccount", - "Trying to switch to user $localUserId...", - ); + logger.t( + 'AuthenticationCubit#switchAccount(): Trying to switch to user $localUserId...'); 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.w( + 'AuthenticationCubit#switchAccount(): User $localUserId not yet registered. ' + 'This should never be the case!', + ); return; } @@ -135,10 +124,8 @@ class AuthenticationCubit extends Cubit { final authenticated = await _localAuthService .authenticateLocalUser("Authenticate to switch your account."); if (!authenticated) { - _debugPrintMessage( - "switchAccount", - "User could not be authenticated.", - ); + logger.w( + "AuthenticationCubit#switchAccount(): User could not be authenticated."); emit(VerifyIdentityState(userId: localUserId)); return; } @@ -151,7 +138,8 @@ class AuthenticationCubit extends Cubit { HiveBoxes.localUserCredentials, (credentialsBox) async { if (!credentialsBox.containsKey(localUserId)) { await credentialsBox.close(); - debugPrint("Invalid authentication for $localUserId"); + logger.w( + "AuthenticationCubit#switchAccount(): Invalid authentication for $localUserId."); return; } final credentials = credentialsBox.get(localUserId); @@ -188,6 +176,8 @@ class AuthenticationCubit extends Cubit { }) async { assert(credentials.password != null && credentials.username != null); final localUserId = "${credentials.username}@$serverUrl"; + logger + .d("AuthenticationCubit#addAccount(): Adding account $localUserId..."); final sessionManager = SessionManager([ LanguageHeaderInterceptor(locale), @@ -204,8 +194,11 @@ class AuthenticationCubit extends Cubit { } Future removeAccount(String userId) async { + logger + .t("AuthenticationCubit#removeAccount(): Removing account $userId..."); final userAccountBox = Hive.localUserAccountBox; final userAppStateBox = Hive.localUserAppStateBox; + await FileService.clearUserData(userId: userId); await userAccountBox.delete(userId); await userAppStateBox.delete(userId); @@ -220,19 +213,15 @@ class AuthenticationCubit extends Cubit { /// Future restoreSession([String? userId]) async { emit(const RestoringSessionState()); - _debugPrintMessage( - "restoreSessionState", - "Trying to restore previous session...", - ); + logger.t( + "AuthenticationCubit#restoreSessionState(): Trying to restore previous session..."); final globalSettings = Hive.box(HiveBoxes.globalSettings).getValue()!; final restoreSessionForUser = userId ?? globalSettings.loggedInUserId; // final localUserId = globalSettings.loggedInUserId; if (restoreSessionForUser == null) { - _debugPrintMessage( - "restoreSessionState", - "There is nothing to restore.", - ); + logger.t( + "AuthenticationCubit#restoreSessionState(): There is nothing to restore."); final otherAccountsExist = Hive.localUserAccountBox.isNotEmpty; // If there is nothing to restore, we can quit here. emit( @@ -243,42 +232,25 @@ 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.t( + "AuthenticationCubit#restoreSessionState(): Verifying user identity..."); final authenticationMesage = (await S.delegate.load(Locale(globalSettings.preferredLocaleSubtag))) .verifyYourIdentity; final localAuthSuccess = await _localAuthService.authenticateLocalUser(authenticationMesage); if (!localAuthSuccess) { + logger.w( + "AuthenticationCubit#restoreSessionState(): Identity could not be verified."); emit(VerifyIdentityState(userId: restoreSessionForUser)); - _debugPrintMessage( - "restoreSessionState", - "User could not be authenticated.", - ); return; } - _debugPrintMessage( - "restoreSessionState", - "User successfully autheticated.", - ); - } else { - _debugPrintMessage( - "restoreSessionState", - "Biometric authentication not configured, skipping.", - ); + logger.t( + "AuthenticationCubit#restoreSessionState(): Identity successfully verified."); } - _debugPrintMessage( - "restoreSessionState", - "Trying to retrieve authentication credentials...", - ); + logger.t( + "AuthenticationCubit#restoreSessionState(): Reading encrypted credentials..."); final authentication = await withEncryptedBox( HiveBoxes.localUserCredentials, (box) { @@ -286,80 +258,62 @@ class AuthenticationCubit extends Cubit { }); if (authentication == null) { - _debugPrintMessage( - "restoreSessionState", - "Could not retrieve existing authentication credentials.", - ); + logger.e( + "AuthenticationCubit#restoreSessionState(): Credentials could not be read!"); throw Exception( "User should be authenticated but no authentication information was found.", ); } + logger.t( + "AuthenticationCubit#restoreSessionState(): Credentials successfully retrieved."); - _debugPrintMessage( - "restoreSessionState", - "Authentication credentials successfully retrieved.", - ); - - _debugPrintMessage( - "restoreSessionState", - "Updating current session state...", - ); + logger.t( + "AuthenticationCubit#restoreSessionState(): Updating security context..."); _sessionManager.updateSettings( clientCertificate: authentication.clientCertificate, authToken: authentication.token, baseUrl: localUserAccount.serverUrl, ); - _debugPrintMessage( - "restoreSessionState", - "Current session state successfully updated.", - ); + logger.t( + "AuthenticationCubit#restoreSessionState(): Security context successfully updated."); final isPaperlessServerReachable = await _connectivityService.isPaperlessServerReachable( localUserAccount.serverUrl, authentication.clientCertificate, ) == ReachabilityStatus.reachable; + logger.t( + "AuthenticationCubit#restoreSessionState(): Trying to update remote paperless user..."); 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.t( + "AuthenticationCubit#restoreSessionState(): Successfully updated remote paperless user."); } else { - _debugPrintMessage( - "restoreSessionMState", - "Skipping update of server user (server could not be reached).", - ); + logger.w( + "AuthenticationCubit#restoreSessionState(): Could not update remote paperless user. Server could not be reached. The app might behave unexpected!"); } globalSettings.loggedInUserId = restoreSessionForUser; await globalSettings.save(); emit(AuthenticatedState(localUserId: restoreSessionForUser)); - _debugPrintMessage( - "restoreSessionState", - "Session was successfully restored.", - ); + logger.t( + "AuthenticationCubit#restoreSessionState(): Previous session successfully restored."); } Future logout([bool removeAccount = false]) async { emit(const LoggingOutState()); - _debugPrintMessage( - "logout", - "Trying to log out current user...", - ); - await _resetExternalState(); final globalSettings = Hive.globalSettingsBox.getValue()!; final userId = globalSettings.loggedInUserId!; + logger.t( + "AuthenticationCubit#logout(): Logging out current user ($userId)..."); + + await _resetExternalState(); await _notificationService.cancelUserNotifications(userId); final otherAccountsExist = Hive.localUserAccountBox.length > 1; @@ -370,15 +324,19 @@ class AuthenticationCubit extends Cubit { globalSettings.loggedInUserId = null; await globalSettings.save(); - _debugPrintMessage( - "logout", - "User successfully logged out.", - ); + logger.t("AuthenticationCubit#logout(): User successfully logged out."); } Future _resetExternalState() async { + logger.t( + "AuthenticationCubit#_resetExternalState(): Resetting security context..."); _sessionManager.resetSettings(); + logger.t( + "AuthenticationCubit#_resetExternalState(): Security context reset."); + logger.t( + "AuthenticationCubit#_resetExternalState(): Clearing local state..."); await HydratedBloc.storage.clear(); + logger.t("AuthenticationCubit#_resetExternalState(): Local state cleard."); } Future _addUser( @@ -392,7 +350,8 @@ class AuthenticationCubit extends Cubit { _FutureVoidCallback? onFetchUserInformation, }) async { assert(credentials.username != null && credentials.password != null); - _debugPrintMessage("_addUser", "Adding new user $localUserId..."); + logger + .t("AuthenticationCubit#_addUser(): Adding new user $localUserId...."); sessionManager.updateSettings( baseUrl: serverUrl, @@ -401,10 +360,8 @@ class AuthenticationCubit extends Cubit { final authApi = _apiFactory.createAuthenticationApi(sessionManager.client); - _debugPrintMessage( - "_addUser", - "Trying to login user ${credentials.username} on $serverUrl...", - ); + logger.t( + "AuthenticationCubit#_addUser(): Fetching bearer token from the server..."); await onPerformLogin?.call(); @@ -413,10 +370,8 @@ class AuthenticationCubit extends Cubit { password: credentials.password!, ); - _debugPrintMessage( - "_addUser", - "Successfully acquired token.", - ); + logger.t( + "AuthenticationCubit#_addUser(): Bearer token successfully retrieved."); sessionManager.updateSettings( baseUrl: serverUrl, @@ -430,18 +385,15 @@ class AuthenticationCubit extends Cubit { Hive.box(HiveBoxes.localUserAppState); if (userAccountBox.containsKey(localUserId)) { - _debugPrintMessage( - "_addUser", - "An error occurred! The user $localUserId already exists.", - ); + logger.w( + "AuthenticationCubit#_addUser(): The user $localUserId already exists."); throw InfoMessageException(code: ErrorCode.userAlreadyExists); } await onFetchUserInformation?.call(); final apiVersion = await _getApiVersion(sessionManager.client); - _debugPrintMessage( - "_addUser", - "Trying to fetch user object for $localUserId...", - ); + logger.t( + "AuthenticationCubit#_addUser(): Trying to fetch remote paperless user for $localUserId."); + late UserModel serverUser; try { serverUser = await _apiFactory @@ -451,21 +403,20 @@ class AuthenticationCubit extends Cubit { ) .findCurrentUser(); } on DioException catch (error, stackTrace) { - _debugPrintMessage( - "_addUser", - "An error occurred: ${error.message}", + logger.e( + "AuthenticationCubit#_addUser(): An error occurred while fetching the remote paperless user.", + error: error, stackTrace: stackTrace, ); + rethrow; } - _debugPrintMessage( - "_addUser", - "User object successfully fetched.", - ); - _debugPrintMessage( - "_addUser", - "Persisting local user account...", - ); + logger.t( + "AuthenticationCubit#_addUser(): Remote paperless user successfully fetched."); + + logger.t( + "AuthenticationCubit#_addUser(): Persisting user account information..."); + await onPersistLocalUserData?.call(); // Create user account await userAccountBox.put( @@ -478,29 +429,21 @@ class AuthenticationCubit extends Cubit { apiVersion: apiVersion, ), ); - _debugPrintMessage( - "_addUser", - "Local user account successfully persisted.", - ); - _debugPrintMessage( - "_addUser", - "Persisting user state...", - ); + logger.t( + "AuthenticationCubit#_addUser(): User account information successfully persisted."); + logger.t("AuthenticationCubit#_addUser(): Persisting user app state..."); // Create user state await userStateBox.put( localUserId, LocalUserAppState(userId: localUserId), ); - _debugPrintMessage( - "_addUser", - "User state successfully persisted.", - ); + logger.t( + "AuthenticationCubit#_addUser(): User state successfully persisted."); // Save credentials in encrypted box await withEncryptedBox(HiveBoxes.localUserCredentials, (box) async { - _debugPrintMessage( - "_addUser", - "Saving user credentials inside encrypted storage...", - ); + logger.t( + "AuthenticationCubit#_addUser(): Saving user credentials inside encrypted storage..."); + await box.put( localUserId, UserCredentials( @@ -508,10 +451,8 @@ class AuthenticationCubit extends Cubit { clientCertificate: clientCert, ), ); - _debugPrintMessage( - "_addUser", - "User credentials successfully saved.", - ); + logger.t( + "AuthenticationCubit#_addUser(): User credentials successfully saved."); }); final hostsBox = Hive.box(HiveBoxes.hosts); if (!hostsBox.values.contains(serverUrl)) { @@ -526,10 +467,8 @@ class AuthenticationCubit extends Cubit { Duration? timeout, int defaultValue = 2, }) async { - _debugPrintMessage( - "_getApiVersion", - "Trying to fetch API version...", - ); + logger.t( + "AuthenticationCubit#_getApiVersion(): Trying to fetch API version..."); try { final response = await dio.get( "/api/", @@ -539,12 +478,13 @@ class AuthenticationCubit extends Cubit { ); final apiVersion = int.parse(response.headers.value('x-api-version') ?? "3"); - _debugPrintMessage( - "_getApiVersion", - "API version ($apiVersion) successfully retrieved.", - ); + logger.t( + "AuthenticationCubit#_getApiVersion(): Successfully retrieved API version ($apiVersion)."); + return apiVersion; } on DioException catch (_) { + logger.w( + "AuthenticationCubit#_getApiVersion(): Could not retrieve API version."); return defaultValue; } } @@ -555,10 +495,8 @@ class AuthenticationCubit extends Cubit { LocalUserAccount localUserAccount, int apiVersion, ) async { - _debugPrintMessage( - "_updateRemoteUser", - "Updating paperless user object...", - ); + logger.t( + "AuthenticationCubit#_updateRemoteUser(): Trying to update remote user object..."); final updatedPaperlessUser = await _apiFactory .createUserApi( sessionManager.client, @@ -568,24 +506,7 @@ class AuthenticationCubit extends Cubit { localUserAccount.paperlessUser = updatedPaperlessUser; await localUserAccount.save(); - _debugPrintMessage( - "_updateRemoteUser", - "Paperless user object successfully updated.", - ); - } - - 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); - } + logger.t( + "AuthenticationCubit#_updateRemoteUser(): Successfully updated remote user object."); } } 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/main.dart b/lib/main.dart index ef06c89..80c2948 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -16,6 +16,7 @@ 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'; @@ -28,6 +29,7 @@ import 'package:paperless_mobile/core/exception/server_message_exception.dart'; import 'package:paperless_mobile/core/factory/paperless_api_factory.dart'; import 'package:paperless_mobile/core/factory/paperless_api_factory_impl.dart'; import 'package:paperless_mobile/core/interceptor/language_header.interceptor.dart'; +import 'package:paperless_mobile/core/logging/logger.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'; @@ -45,6 +47,7 @@ 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 +85,7 @@ Future performMigrations() async { final requiresMigrationForCurrentVersion = !performedMigrations.contains(currentVersion); if (requiresMigrationForCurrentVersion) { - debugPrint("Applying migration scripts for version $currentVersion"); + logger.t("Applying migration scripts for version $currentVersion"); await migrationProcedure(); await sp.setStringList( 'performed_migrations', @@ -91,7 +94,6 @@ Future performMigrations() async { } } - Future _initHive() async { await Hive.initFlutter(); @@ -125,6 +127,13 @@ void main() async { // ) // .start(); // } + + logger = l.Logger( + output: MirroredFileOutput(), + printer: SpringBootLikePrinter(), + level: l.Level.trace, + ); + packageInfo = await PackageInfo.fromPlatform(); if (Platform.isAndroid) { @@ -160,6 +169,15 @@ void main() async { // Manages security context, required for self signed client certificates final sessionManager = SessionManager([ languageHeaderInterceptor, + PrettyDioLogger( + compact: true, + responseBody: false, + responseHeader: false, + request: false, + requestBody: false, + requestHeader: false, + logPrint: (object) => logger.t, + ), ]); // Initialize Blocs/Cubits @@ -214,9 +232,7 @@ void main() async { ServerMessageException e => e.message, _ => error.toString() }; - debugPrint("An unepxected exception has occured!"); - debugPrint(message); - debugPrintStack(stackTrace: stack); + logger.e(message, stackTrace: stack); }); } @@ -254,7 +270,7 @@ class _GoRouterShellState extends State { final DisplayMode mostOptimalMode = sameResolution.isNotEmpty ? sameResolution.first : active; - debugPrint('Setting refresh rate to ${mostOptimalMode.refreshRate}'); + logger.d('Setting refresh rate to ${mostOptimalMode.refreshRate}'); await FlutterDisplayMode.setPreferredMode(mostOptimalMode); } diff --git a/pubspec.lock b/pubspec.lock index 8585aac..a13759c 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -997,6 +997,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: diff --git a/pubspec.yaml b/pubspec.yaml index 561da11..20a9a79 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -98,6 +98,7 @@ dependencies: flutter_animate: ^4.2.0+1 shared_preferences: ^2.2.1 flutter_markdown: ^0.6.18 + logger: ^2.0.2+1 # camerawesome: ^2.0.0-dev.1 dependency_overrides: From 7d1c0dffe4ec891de75dbf6a82bc8aafaef2e160 Mon Sep 17 00:00:00 2001 From: Anton Stubenbord Date: Thu, 12 Oct 2023 17:50:13 +0200 Subject: [PATCH 2/6] feat: finished new logging feature --- lib/core/logging/cubit/app_logs_cubit.dart | 111 ++++ lib/core/logging/cubit/app_logs_state.dart | 33 ++ lib/core/logging/data/formatted_printer.dart | 44 ++ lib/core/logging/data/logger.dart | 116 ++++ .../logging/data/mirrored_file_output.dart | 54 ++ lib/core/logging/logger.dart | 103 ---- .../logging/models/formatted_log_message.dart | 19 + .../logging/models/parsed_log_message.dart | 148 +++++ lib/core/logging/utils/redaction_utils.dart | 22 + lib/core/logging/view/app_logs_page.dart | 525 ++++++++---------- lib/core/service/file_service.dart | 323 +++++++---- lib/extensions/dart_extensions.dart | 4 + lib/features/app_drawer/view/app_drawer.dart | 11 +- .../cubit/document_details_cubit.dart | 12 +- .../cubit/document_scanner_cubit.dart | 18 +- .../document_scan/view/scanner_page.dart | 3 +- .../document_upload_preparation_page.dart | 6 +- lib/features/inbox/cubit/inbox_cubit.dart | 32 +- .../labels/view/pages/labels_page.dart | 24 +- .../login/cubit/authentication_cubit.dart | 348 ++++++++---- .../view/widgets/clear_storage_settings.dart | 34 +- .../sharing/cubit/receive_share_cubit.dart | 10 +- .../view/widgets/event_listener_shell.dart | 1 - lib/l10n/intl_ca.arb | 10 +- lib/l10n/intl_cs.arb | 10 +- lib/l10n/intl_de.arb | 10 +- lib/l10n/intl_en.arb | 10 +- lib/l10n/intl_es.arb | 10 +- lib/l10n/intl_fr.arb | 10 +- lib/l10n/intl_pl.arb | 10 +- lib/l10n/intl_ru.arb | 10 +- lib/l10n/intl_tr.arb | 10 +- lib/main.dart | 44 +- .../typed/top_level/app_logs_route.dart | 26 + .../typed/top_level/changelog_route.dart | 2 +- pubspec.lock | 2 +- pubspec.yaml | 1 + 37 files changed, 1446 insertions(+), 720 deletions(-) create mode 100644 lib/core/logging/cubit/app_logs_cubit.dart create mode 100644 lib/core/logging/cubit/app_logs_state.dart create mode 100644 lib/core/logging/data/formatted_printer.dart create mode 100644 lib/core/logging/data/logger.dart create mode 100644 lib/core/logging/data/mirrored_file_output.dart delete mode 100644 lib/core/logging/logger.dart create mode 100644 lib/core/logging/models/formatted_log_message.dart create mode 100644 lib/core/logging/models/parsed_log_message.dart create mode 100644 lib/core/logging/utils/redaction_utils.dart create mode 100644 lib/routes/typed/top_level/app_logs_route.dart diff --git a/lib/core/logging/cubit/app_logs_cubit.dart b/lib/core/logging/cubit/app_logs_cubit.dart new file mode 100644 index 0000000..7ff127a --- /dev/null +++ b/lib/core/logging/cubit/app_logs_cubit.dart @@ -0,0 +1,111 @@ +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/core/logging/models/parsed_log_message.dart'; +import 'package:paperless_mobile/core/service/file_service.dart'; +import 'package:path/path.dart' as p; +import 'package:rxdart/rxdart.dart'; +part 'app_logs_state.dart'; + +final _fileNameFormat = DateFormat("yyyy-MM-dd"); + +class AppLogsCubit extends Cubit { + StreamSubscription? _fileChangesSubscription; + AppLogsCubit(DateTime date) : 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 { + final logs = await logFile.readAsLines(); + final parsedLogs = + ParsedLogMessage.parse(logs.skip(2000).toList()).reversed.toList(); + _fileChangesSubscription = logFile.watch().listen((event) async { + if (!isClosed) { + final logs = await logFile.readAsLines(); + emit(AppLogsStateLoaded( + date: date, + logs: parsedLogs, + availableLogs: availableLogs, + )); + } + }); + emit(AppLogsStateLoaded( + date: date, + logs: parsedLogs, + availableLogs: availableLogs, + )); + } catch (e) { + emit(AppLogsStateError( + error: e, + date: date, + )); + } + } + + 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, + ); + final logFile = _getLogfile(date); + if (parentDir != null) { + await logFile.copy(p.join(parentDir, filename)); + } + } + + File _getLogfile(DateTime date) { + return File(p.join(FileService.instance.logDirectory.path, + '${_fileNameFormat.format(date)}.log')); + } + + @override + Future close() { + _fileChangesSubscription?.cancel(); + return super.close(); + } +} diff --git a/lib/core/logging/cubit/app_logs_state.dart b/lib/core/logging/cubit/app_logs_state.dart new file mode 100644 index 0000000..f8b1966 --- /dev/null +++ b/lib/core/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/core/logging/data/formatted_printer.dart b/lib/core/logging/data/formatted_printer.dart new file mode 100644 index 0000000..63d88c2 --- /dev/null +++ b/lib/core/logging/data/formatted_printer.dart @@ -0,0 +1,44 @@ +import 'dart:convert'; + +import 'package:collection/collection.dart'; +import 'package:intl/intl.dart'; +import 'package:logger/logger.dart'; +import 'package:paperless_mobile/core/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/core/logging/data/logger.dart b/lib/core/logging/data/logger.dart new file mode 100644 index 0000000..e7016be --- /dev/null +++ b/lib/core/logging/data/logger.dart @@ -0,0 +1,116 @@ +import 'package:logger/logger.dart'; +import 'package:paperless_mobile/core/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/core/logging/data/mirrored_file_output.dart b/lib/core/logging/data/mirrored_file_output.dart new file mode 100644 index 0000000..8feff46 --- /dev/null +++ b/lib/core/logging/data/mirrored_file_output.dart @@ -0,0 +1,54 @@ +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'; + +class MirroredFileOutput extends LogOutput { + final Completer _initCompleter = Completer(); + 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}."); + _initCompleter.complete(); + 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); + if (_initCompleter.isCompleted) { + await file.writeAsString( + "$line${Platform.lineTerminator}", + mode: FileMode.append, + ); + } + } + }); + } +} diff --git a/lib/core/logging/logger.dart b/lib/core/logging/logger.dart deleted file mode 100644 index 1a5fbe4..0000000 --- a/lib/core/logging/logger.dart +++ /dev/null @@ -1,103 +0,0 @@ -import 'dart:async'; -import 'dart:convert'; -import 'dart:io'; - -import 'package:collection/collection.dart'; -import 'package:flutter/widgets.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:rxdart/rxdart.dart'; - -late Logger logger; - -class MirroredFileOutput extends LogOutput { - late final File file; - final Completer _initCompleter = Completer(); - MirroredFileOutput(); - - @override - Future init() async { - final today = DateFormat("yyyy-MM-dd").format(DateTime.now()); - final logDir = await FileService.logDirectory; - file = File(p.join(logDir.path, '$today.log')); - debugPrint("Logging files to ${file.path}."); - _initCompleter.complete(); - try { - final oldLogs = await logDir.list().whereType().toList(); - 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 { - for (var line in event.lines) { - debugPrint(line); - if (_initCompleter.isCompleted) { - await file.writeAsString( - "$line\n", - mode: FileMode.append, - ); - } - } - } -} - -class SpringBootLikePrinter extends LogPrinter { - SpringBootLikePrinter(); - static final _timestampFormat = DateFormat("yyyy-MM-dd HH:mm:ss.SSS"); - - @override - List log(LogEvent event) { - final level = _buildLeftAligned(event.level.name.toUpperCase(), - Level.values.map((e) => e.name.length).max); - String message = _stringifyMessage(event.message); - final timestamp = - _buildLeftAligned(_timestampFormat.format(event.time), 23); - final traceRegex = RegExp(r"(.*)#(.*)\(\): (.*)"); - final match = traceRegex.firstMatch(message); - if (match != null) { - final className = match.group(1)!; - final methodName = match.group(2)!; - final remainingMessage = match.group(3)!; - final formattedClassName = _buildRightAligned(className, 25); - final formattedMethodName = _buildLeftAligned(methodName, 25); - message = message.replaceFirst(traceRegex, - "[$formattedClassName] - $formattedMethodName: $remainingMessage"); - } else { - message = List.filled(55, " ").join("") + ": " + message; - } - return [ - '$timestamp\t$level --- $message', - if (event.error != null) '\t\t${event.error}', - if (event.stackTrace != null) '\t\t${event.stackTrace.toString()}', - ]; - } - - String _buildLeftAligned(String message, int maxLength) { - return message.padRight(maxLength, ' '); - } - - String _buildRightAligned(String message, int maxLength) { - return message.padLeft(maxLength, ' '); - } - - String _stringifyMessage(dynamic message) { - final finalMessage = message is Function ? message() : message; - if (finalMessage is Map || finalMessage is Iterable) { - var encoder = const JsonEncoder.withIndent(null); - return encoder.convert(finalMessage); - } else { - return finalMessage.toString(); - } - } -} diff --git a/lib/core/logging/models/formatted_log_message.dart b/lib/core/logging/models/formatted_log_message.dart new file mode 100644 index 0000000..94b13ce --- /dev/null +++ b/lib/core/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/core/logging/models/parsed_log_message.dart b/lib/core/logging/models/parsed_log_message.dart new file mode 100644 index 0000000..3c121cc --- /dev/null +++ b/lib/core/logging/models/parsed_log_message.dart @@ -0,0 +1,148 @@ +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/core/logging/utils/redaction_utils.dart b/lib/core/logging/utils/redaction_utils.dart new file mode 100644 index 0000000..724e6fa --- /dev/null +++ b/lib/core/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/core/logging/view/app_logs_page.dart b/lib/core/logging/view/app_logs_page.dart index 3f127b9..0ba82ce 100644 --- a/lib/core/logging/view/app_logs_page.dart +++ b/lib/core/logging/view/app_logs_page.dart @@ -1,19 +1,13 @@ -import 'dart:async'; -import 'dart:io'; - -import 'package:file_picker/file_picker.dart'; import 'package:flutter/cupertino.dart'; import 'package:flutter/material.dart'; -import 'package:flutter/services.dart'; -import 'package:flutter_animate/flutter_animate.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:intl/intl.dart'; import 'package:logger/logger.dart'; -import 'package:paperless_mobile/core/service/file_service.dart'; +import 'package:paperless_mobile/core/logging/cubit/app_logs_cubit.dart'; +import 'package:paperless_mobile/core/logging/models/parsed_log_message.dart'; +import 'package:paperless_mobile/extensions/dart_extensions.dart'; import 'package:paperless_mobile/extensions/flutter_extensions.dart'; -import 'package:path/path.dart' as p; -import 'package:rxdart/subjects.dart'; - -final _fileNameFormat = DateFormat("yyyy-MM-dd"); +import 'package:paperless_mobile/generated/l10n/app_localizations.dart'; class AppLogsPage extends StatefulWidget { const AppLogsPage({super.key}); @@ -23,304 +17,181 @@ class AppLogsPage extends StatefulWidget { } class _AppLogsPageState extends State { - final _fileContentStream = BehaviorSubject(); final ScrollController _scrollController = ScrollController(); - StreamSubscription? _fileChangesSubscription; - - late DateTime _date; - File? file; bool autoScroll = true; - List? _availableLogs; - - Future _initFile() async { - final logDir = await FileService.logDirectory; - // logDir.listSync().whereType().forEach((element) { - // element.deleteSync(); - // }); - if (logDir.listSync().isEmpty) { - return; - } - - WidgetsBinding.instance.addPostFrameCallback((timeStamp) async { - final filename = _fileNameFormat.format(_date); - setState(() { - file = File(p.join(logDir.path, '$filename.log')); - }); - _scrollController.addListener(_initialScrollListener); - _updateFileContent(); - _fileChangesSubscription?.cancel(); - _fileChangesSubscription = file!.watch().listen((event) async { - await _updateFileContent(); - }); - }); - } - - void _initialScrollListener() { - if (_scrollController.positions.isNotEmpty) { - _scrollController.animateTo( - 0, - duration: 500.milliseconds, - curve: Curves.easeIn, - ); - _scrollController.removeListener(_initialScrollListener); - } - } - - @override - void initState() { - super.initState(); - _date = DateTime.now().copyWith( - minute: 0, - hour: 0, - second: 0, - millisecond: 0, - microsecond: 0, - ); - _initFile(); - () async { - final logDir = await FileService.logDirectory; - final files = logDir.listSync(followLinks: false).whereType(); - final fileNames = files.map((e) => p.basenameWithoutExtension(e.path)); - final dates = - fileNames.map((filename) => _fileNameFormat.parseStrict(filename)); - _availableLogs = dates.toList(); - }(); - } - - @override - void dispose() { - _fileChangesSubscription?.cancel(); - super.dispose(); - } @override Widget build(BuildContext context) { final locale = Localizations.localeOf(context).toString(); final theme = Theme.of(context); - return Scaffold( - appBar: AppBar( - title: Row( - mainAxisSize: MainAxisSize.min, - children: [ - Text("Logs"), - SizedBox(width: 16), - DropdownButton( - - value: _date, - items: [ - for (var date in _availableLogs ?? []) - DropdownMenuItem( - child: Text(DateFormat.yMMMd(locale).format(date)), - value: date, - ), - ], - onChanged: (value) { - if (value != null) { - setState(() { - _date = value; - }); - _initFile(); - } + 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(), + ], + _ => [], }, ), - ], - ), - actions: file != null - ? [ + ), + appBar: AppBar( + title: Text(S.of(context)!.appLogs(formattedDate)), + actions: [ + if (state is AppLogsStateLoaded) IconButton( - tooltip: "Save log file to selected directory", - onPressed: () => _saveFile(locale), - icon: const Icon(Icons.download), - ), - IconButton( - tooltip: "Copy logs to clipboard", - onPressed: _copyToClipboard, - icon: const Icon(Icons.copy), + 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(), - ] - : null, - ), - body: Builder( - builder: (context) { - if (_availableLogs == null) { - return Center( - child: Text("No logs available."), - ); - } - return StreamBuilder( - stream: _fileContentStream, - builder: (context, snapshot) { - if (!snapshot.hasData || file == null) { - return const Center( - child: Text( - "Initializing logs...", - ), - ); - } - final messages = _transformLog(snapshot.data!).reversed.toList(); - return ColoredBox( - color: theme.colorScheme.background, - child: Column( - children: [ - Expanded( - child: ListView.builder( - reverse: true, - controller: _scrollController, - itemBuilder: (context, index) { - if (index == 0) { - return Center( - child: Text( - "End of logs.", - style: theme.textTheme.labelLarge?.copyWith( - fontStyle: FontStyle.italic, - ), - ), - ).padded(24); - } - final logMessage = messages[index - 1]; - final altColor = CupertinoDynamicColor.withBrightness( - color: Colors.grey.shade200, - darkColor: Colors.grey.shade800, - ).resolveFrom(context); - return _LogMessageWidget( - message: logMessage, - backgroundColor: (index % 2 == 0) - ? theme.colorScheme.background - : altColor, - ); - }, - itemCount: messages.length + 1, - ), - ), - ], + ], + ), + 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)), + ], ), ); } - - Future _saveFile(String locale) async { - assert(file != null); - 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 ? "/storage/emulated/0/Download/" : null, - ); - if (parentDir != null) { - await file!.copy(p.join(parentDir, filename)); - } - } - - Future _copyToClipboard() async { - assert(file != null); - final content = await file!.readAsString(); - await Clipboard.setData(ClipboardData(text: content)); - } - - List<_LogMessage> _transformLog(String log) { - List<_LogMessage> messages = []; - List currentCoherentLines = []; - final lines = log.split("\n"); - for (var line in lines) { - final isMatch = _LogMessage.hasMatch(line); - if (currentCoherentLines.isNotEmpty && isMatch) { - messages.add(_LogMessage(message: currentCoherentLines.join("\n"))); - currentCoherentLines.clear(); - messages.add(_LogMessage.fromMessage(line)); - } - if (_LogMessage.hasMatch(line)) { - messages.add(_LogMessage.fromMessage(line)); - } else { - currentCoherentLines.add(line); - } - } - - return messages; - } - - Future _updateFileContent() async { - final content = await file!.readAsString(); - _fileContentStream.add(content); - Future.delayed(400.milliseconds, () { - _scrollController.animateTo( - 0, - duration: 500.milliseconds, - curve: Curves.easeIn, - ); - }); - } } -class _LogMessage { - 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; - - bool get isFormatted => level != null; - const _LogMessage({ - this.level, - required this.message, - this.className, - this.methodName, - this.timestamp, - }); - - static bool hasMatch(String message) => pattern.hasMatch(message); - - factory _LogMessage.fromMessage(String message) { - final match = pattern.firstMatch(message); - if (match == null) { - return _LogMessage(message: message); - } - return _LogMessage( - level: Level.values.byName(match.namedGroup('level')!.toLowerCase()), - message: match.namedGroup('message')!, - className: match.namedGroup('className'), - methodName: match.namedGroup('methodName'), - timestamp: DateTime.tryParse(match.namedGroup('timestamp') ?? ''), - ); - } -} - -class _LogMessageWidget extends StatelessWidget { - final _LogMessage message; +class ParsedLogMessageTile extends StatelessWidget { + final ParsedLogMessage message; final Color backgroundColor; - const _LogMessageWidget({ + + const ParsedLogMessageTile({ + super.key, required this.message, required this.backgroundColor, }); @override Widget build(BuildContext context) { - final c = Theme.of(context).colorScheme; - if (!message.isFormatted) { - return Text( - message.message, - style: Theme.of(context).textTheme.bodySmall?.copyWith( - fontSize: 5, - color: c.onBackground.withOpacity(0.7), - ), - ); - } - 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, - _ => c.onBackground, + 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, @@ -330,31 +201,83 @@ class _LogMessageWidget extends StatelessWidget { 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( - child: ListTile( + 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, ), - tileColor: backgroundColor, - title: Text( - message.message, - style: TextStyle(color: color), - ), - subtitle: message.className != null - ? Text( - "${message.className ?? ''} ${message.methodName ?? ''}", - style: TextStyle( - color: color.withOpacity(0.75), - fontSize: 10, - fontFamily: "monospace", + 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), + ), + ), + ], ), - ) - : null, - leading: message.timestamp != null - ? Text(DateFormat("HH:mm:ss.SSS").format(message.timestamp!)) - : null, + ..._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/core/service/file_service.dart b/lib/core/service/file_service.dart index 81c53b7..83d0ebe 100644 --- a/lib/core/service/file_service.dart +++ b/lib/core/service/file_service.dart @@ -1,166 +1,260 @@ import 'dart:io'; import 'package:flutter/foundation.dart'; -import 'package:paperless_mobile/core/logging/logger.dart'; +import 'package:paperless_mobile/core/logging/data/logger.dart'; +import 'package:paperless_mobile/core/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 logDirectory async { - if (Platform.isAndroid) { - return getExternalStorageDirectories(type: StorageDirectory.documents) - .then((directory) async => - directory?.firstOrNull ?? - await getApplicationDocumentsDirectory()) - .then((directory) => - Directory('${directory.path}/logs').create(recursive: true)); - } else if (Platform.isIOS) { - return getApplicationDocumentsDirectory().then( - (value) => Directory('${value.path}/logs').create(recursive: true)); - } - throw UnsupportedError("Platform not supported."); + Future getConsumptionDirectory({required String userId}) async { + return Directory(p.join(_uploadDirectory.path, userId)) + .create(recursive: true); } - static Future get downloadsDirectory async { - if (Platform.isAndroid) { - var 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 clearUserData({required String userId}) async { + final redactedId = redactUserId(userId); + logger.fd( + "Clearing data for user $redactedId...", + className: runtimeType.toString(), + methodName: "clearUserData", + ); - static Future get uploadDirectory async { - final dir = await getApplicationDocumentsDirectory() - .then((dir) => Directory('${dir.path}/upload')); - return dir.create(recursive: true); - } - - 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 { - logger.t("FileService#clearUserData(): Clearing data for user $userId..."); - - final scanDir = await temporaryScansDirectory; - final scanDirSize = formatBytes(await getDirSizeInBytes(scanDir)); - final tempDir = await temporaryDirectory; - final tempDirSize = formatBytes(await getDirSizeInBytes(tempDir)); + final scanDirSize = + formatBytes(await getDirSizeInBytes(_temporaryScansDirectory)); + final tempDirSize = + formatBytes(await getDirSizeInBytes(_temporaryDirectory)); final consumptionDir = await getConsumptionDirectory(userId: userId); final consumptionDirSize = formatBytes(await getDirSizeInBytes(consumptionDir)); - logger.t("FileService#clearUserData(): Removing scans..."); - await scanDir.delete(recursive: true); - logger.t("FileService#clearUserData(): Removed $scanDirSize..."); + 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", + ); - logger.t( - "FileService#clearUserData(): Removing temporary files and cache content..."); + await _temporaryDirectory.delete(recursive: true); + logger.ft( + "Removed $tempDirSize...", + className: runtimeType.toString(), + methodName: "clearUserData", + ); - await tempDir.delete(recursive: true); - logger.t("FileService#clearUserData(): Removed $tempDirSize..."); - - logger.t( - "FileService#clearUserData(): Removing files waiting for consumption..."); + logger.ft( + "Removing files waiting for consumption...", + className: runtimeType.toString(), + methodName: "clearUserData", + ); await consumptionDir.delete(recursive: true); - logger.t("FileService#clearUserData(): Removed $consumptionDirSize..."); - } - - 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(); } - static Future getDirSizeInBytes(Directory dir) async { + 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); + return; + } + _downloadsDirectory = directory; + } 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 { @@ -168,5 +262,6 @@ enum PaperlessDirectoryType { temporary, scans, download, - upload; + upload, + logs; } diff --git a/lib/extensions/dart_extensions.dart b/lib/extensions/dart_extensions.dart index 9b23841..9bca5ad 100644 --- a/lib/extensions/dart_extensions.dart +++ b/lib/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/features/app_drawer/view/app_drawer.dart b/lib/features/app_drawer/view/app_drawer.dart index 9144dec..f569109 100644 --- a/lib/features/app_drawer/view/app_drawer.dart +++ b/lib/features/app_drawer/view/app_drawer.dart @@ -2,12 +2,9 @@ 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/logging/view/app_logs_page.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/documents/cubit/documents_cubit.dart'; @@ -19,6 +16,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'; @@ -185,12 +183,9 @@ class AppDrawer extends StatelessWidget { ListTile( dense: true, leading: const Icon(Icons.subject), - title: const Text('Logs'), //TODO: INTL + title: Text(S.of(context)!.appLogs('')), onTap: () { - Navigator.of(context) - .push(MaterialPageRoute(builder: (context) { - return const AppLogsPage(); - })); + AppLogsRoute().push(context); }, ), ListTile( diff --git a/lib/features/document_details/cubit/document_details_cubit.dart b/lib/features/document_details/cubit/document_details_cubit.dart index e5c10cb..f28ff2b 100644 --- a/lib/features/document_details/cubit/document_details_cubit.dart +++ b/lib/features/document_details/cubit/document_details_cubit.dart @@ -6,7 +6,7 @@ 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/logging/logger.dart'; +import 'package:paperless_mobile/core/logging/data/logger.dart'; import 'package:paperless_mobile/core/notifier/document_changed_notifier.dart'; import 'package:paperless_mobile/core/repository/label_repository.dart'; import 'package:paperless_mobile/core/service/file_service.dart'; @@ -85,7 +85,7 @@ class DocumentDetailsCubit extends Cubit { } Future openDocumentInSystemViewer() async { - final cacheDir = await FileService.temporaryDirectory; + final cacheDir = FileService.instance.temporaryDirectory; if (state.metaData == null) { await loadMetaData(); } @@ -121,7 +121,7 @@ class DocumentDetailsCubit extends Cubit { } String targetPath = _buildDownloadFilePath( downloadOriginal, - await FileService.downloadsDirectory, + FileService.instance.downloadsDirectory, ); if (!await File(targetPath).exists()) { @@ -170,7 +170,7 @@ class DocumentDetailsCubit extends Cubit { locale: locale, userId: userId, ); - logger.i("Document '${state.document.title}' saved to $targetPath."); + logger.fi("Document '${state.document.title}' saved to $targetPath."); } Future shareDocument({bool shareOriginal = false}) async { @@ -179,7 +179,7 @@ class DocumentDetailsCubit extends Cubit { } String filePath = _buildDownloadFilePath( shareOriginal, - await FileService.temporaryDirectory, + FileService.instance.temporaryDirectory, ); await _api.downloadToFile( state.document, @@ -204,7 +204,7 @@ class DocumentDetailsCubit extends Cubit { await loadMetaData(); } final filePath = - _buildDownloadFilePath(false, await FileService.temporaryDirectory); + _buildDownloadFilePath(false, FileService.instance.temporaryDirectory); await _api.downloadToFile( state.document, filePath, diff --git a/lib/features/document_scan/cubit/document_scanner_cubit.dart b/lib/features/document_scan/cubit/document_scanner_cubit.dart index 9471dc0..364ce7f 100644 --- a/lib/features/document_scan/cubit/document_scanner_cubit.dart +++ b/lib/features/document_scan/cubit/document_scanner_cubit.dart @@ -4,7 +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/core/logging/logger.dart'; +import 'package:paperless_mobile/core/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'; @@ -19,13 +19,21 @@ class DocumentScannerCubit extends Cubit { : super(const InitialDocumentScannerState()); Future initialize() async { - logger.t("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(); - logger.t("Restored ${scans.length} scans."); + logger.fd( + "Restored ${scans.length} scans.", + className: runtimeType.toString(), + methodName: "initialize", + ); emit( scans.isEmpty ? const InitialDocumentScannerState() @@ -75,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..316c3a2 100644 --- a/lib/features/document_scan/view/scanner_page.dart +++ b/lib/features/document_scan/view/scanner_page.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_upload/view/document_upload_preparation_page.dart b/lib/features/document_upload/view/document_upload_preparation_page.dart index b120fd9..bd0d8d5 100644 --- a/lib/features/document_upload/view/document_upload_preparation_page.dart +++ b/lib/features/document_upload/view/document_upload_preparation_page.dart @@ -12,7 +12,7 @@ import 'package:paperless_api/paperless_api.dart'; import 'package:paperless_mobile/core/config/hive/hive_config.dart'; import 'package:paperless_mobile/core/database/tables/global_settings.dart'; import 'package:paperless_mobile/core/database/tables/local_user_account.dart'; -import 'package:paperless_mobile/core/logging/logger.dart'; +import 'package:paperless_mobile/core/logging/data/logger.dart'; import 'package:paperless_mobile/core/repository/label_repository.dart'; import 'package:paperless_mobile/core/widgets/future_or_builder.dart'; import 'package:paperless_mobile/extensions/flutter_extensions.dart'; @@ -378,8 +378,10 @@ class _DocumentUploadPreparationPageState } on PaperlessFormValidationException catch (exception) { setState(() => _errors = exception.validationMessages); } catch (error, stackTrace) { - logger.e( + logger.fe( "An unknown error occurred during document upload.", + className: runtimeType.toString(), + methodName: "_onSubmit", error: error, stackTrace: stackTrace, ); diff --git a/lib/features/inbox/cubit/inbox_cubit.dart b/lib/features/inbox/cubit/inbox_cubit.dart index c4c3430..b67f012 100644 --- a/lib/features/inbox/cubit/inbox_cubit.dart +++ b/lib/features/inbox/cubit/inbox_cubit.dart @@ -1,16 +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/core/logging/logger.dart'; +import 'package:paperless_mobile/core/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'; @@ -50,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)); @@ -84,17 +77,26 @@ class InboxCubit extends HydratedCubit } Future refreshItemsInInboxCount([bool shouldLoadInbox = true]) async { - logger.t( - "InboxCubit#refreshItemsInInboxCount(): Checking for new documents 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.t( - "InboxCubit#refreshItemsInInboxCount(): New documents found in inbox, reloading inbox."); + logger.fi( + "New documents found in inbox, reloading.", + className: runtimeType.toString(), + methodName: "refreshItemsInInboxCount", + ); await loadInbox(); } else { - logger.t( - "InboxCubit#refreshItemsInInboxCount(): No new documents found in inbox."); + logger.fi( + "No new documents found in inbox.", + className: runtimeType.toString(), + methodName: "refreshItemsInInboxCount", + ); } emit(state.copyWith(itemsInInboxCount: stats.documentsInInbox)); } diff --git a/lib/features/labels/view/pages/labels_page.dart b/lib/features/labels/view/pages/labels_page.dart index 12368d2..7f0b71b 100644 --- a/lib/features/labels/view/pages/labels_page.dart +++ b/lib/features/labels/view/pages/labels_page.dart @@ -7,7 +7,7 @@ import 'package:paperless_mobile/core/config/hive/hive_config.dart'; import 'package:paperless_mobile/core/database/tables/global_settings.dart'; import 'package:paperless_mobile/core/database/tables/local_user_account.dart'; import 'package:paperless_mobile/core/delegate/customizable_sliver_persistent_header_delegate.dart'; -import 'package:paperless_mobile/core/logging/logger.dart'; +import 'package:paperless_mobile/core/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'; @@ -213,16 +213,18 @@ class _LabelsPageState extends State ][_currentIndex] .call(); } catch (error, stackTrace) { - logger.e( - "An error ocurred while reloading " - "${[ - "correspondents", - "document types", - "tags", - "storage paths" - ][_currentIndex]}: ${error.toString()}", - 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/login/cubit/authentication_cubit.dart b/lib/features/login/cubit/authentication_cubit.dart index 2631069..f59223c 100644 --- a/lib/features/login/cubit/authentication_cubit.dart +++ b/lib/features/login/cubit/authentication_cubit.dart @@ -1,5 +1,6 @@ import 'package:dio/dio.dart'; import 'package:equatable/equatable.dart'; +import 'package:flutter/foundation.dart'; import 'package:flutter/widgets.dart'; import 'package:hive_flutter/adapters.dart'; import 'package:hydrated_bloc/hydrated_bloc.dart'; @@ -13,7 +14,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/core/logging/logger.dart'; +import 'package:paperless_mobile/core/logging/data/logger.dart'; +import 'package:paperless_mobile/core/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'; @@ -56,7 +58,13 @@ class AuthenticationCubit extends Cubit { } emit(const AuthenticatingState(AuthenticatingStage.authenticating)); final localUserId = "${credentials.username}@$serverUrl"; - logger.t("AuthenticationCubit#login(): Trying to log in $localUserId..."); + final redactedId = redactUserId(localUserId); + + logger.fd( + "Trying to log in $redactedId...", + className: runtimeType.toString(), + methodName: 'login', + ); try { await _addUser( localUserId, @@ -95,15 +103,22 @@ class AuthenticationCubit extends Cubit { await globalSettings.save(); emit(AuthenticatedState(localUserId: localUserId)); - logger.t( - 'AuthenticationCubit#login(): User $localUserId 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()); - logger.t( - 'AuthenticationCubit#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()!; @@ -111,9 +126,11 @@ class AuthenticationCubit extends Cubit { final userAccountBox = Hive.localUserAccountBox; if (!userAccountBox.containsKey(localUserId)) { - logger.w( - 'AuthenticationCubit#switchAccount(): User $localUserId not yet registered. ' + logger.fw( + 'User $redactedId not yet registered. ' 'This should never be the case!', + className: runtimeType.toString(), + methodName: 'switchAccount', ); return; } @@ -124,8 +141,11 @@ class AuthenticationCubit extends Cubit { final authenticated = await _localAuthService .authenticateLocalUser("Authenticate to switch your account."); if (!authenticated) { - logger.w( - "AuthenticationCubit#switchAccount(): User could not be authenticated."); + logger.fw( + "User could not be authenticated.", + className: runtimeType.toString(), + methodName: 'switchAccount', + ); emit(VerifyIdentityState(userId: localUserId)); return; } @@ -138,8 +158,11 @@ class AuthenticationCubit extends Cubit { HiveBoxes.localUserCredentials, (credentialsBox) async { if (!credentialsBox.containsKey(localUserId)) { await credentialsBox.close(); - logger.w( - "AuthenticationCubit#switchAccount(): Invalid authentication for $localUserId."); + logger.fw( + "Invalid authentication for $redactedId.", + className: runtimeType.toString(), + methodName: 'switchAccount', + ); return; } final credentials = credentialsBox.get(localUserId); @@ -176,8 +199,12 @@ class AuthenticationCubit extends Cubit { }) async { assert(credentials.password != null && credentials.username != null); final localUserId = "${credentials.username}@$serverUrl"; - logger - .d("AuthenticationCubit#addAccount(): Adding account $localUserId..."); + final redactedId = redactUserId(localUserId); + logger.fd( + "Adding account $redactedId...", + className: runtimeType.toString(), + methodName: 'switchAccount', + ); final sessionManager = SessionManager([ LanguageHeaderInterceptor(locale), @@ -194,12 +221,16 @@ class AuthenticationCubit extends Cubit { } Future removeAccount(String userId) async { - logger - .t("AuthenticationCubit#removeAccount(): Removing account $userId..."); + 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( @@ -213,15 +244,21 @@ class AuthenticationCubit extends Cubit { /// Future restoreSession([String? userId]) async { emit(const RestoringSessionState()); - logger.t( - "AuthenticationCubit#restoreSessionState(): Trying to restore previous session..."); + 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) { - logger.t( - "AuthenticationCubit#restoreSessionState(): There is nothing to restore."); + 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. emit( @@ -233,24 +270,36 @@ class AuthenticationCubit extends Cubit { Hive.box(HiveBoxes.localUserAccount); final localUserAccount = localUserAccountBox.get(restoreSessionForUser)!; if (localUserAccount.settings.isBiometricAuthenticationEnabled) { - logger.t( - "AuthenticationCubit#restoreSessionState(): Verifying user identity..."); + logger.fd( + "Verifying user identity...", + className: runtimeType.toString(), + methodName: 'restoreSession', + ); final authenticationMesage = (await S.delegate.load(Locale(globalSettings.preferredLocaleSubtag))) .verifyYourIdentity; final localAuthSuccess = await _localAuthService.authenticateLocalUser(authenticationMesage); if (!localAuthSuccess) { - logger.w( - "AuthenticationCubit#restoreSessionState(): Identity could not be verified."); + logger.fw( + "Identity could not be verified.", + className: runtimeType.toString(), + methodName: 'restoreSession', + ); emit(VerifyIdentityState(userId: restoreSessionForUser)); return; } - logger.t( - "AuthenticationCubit#restoreSessionState(): Identity successfully verified."); + logger.fd( + "Identity successfully verified.", + className: runtimeType.toString(), + methodName: 'restoreSession', + ); } - logger.t( - "AuthenticationCubit#restoreSessionState(): Reading encrypted credentials..."); + logger.fd( + "Reading encrypted credentials...", + className: runtimeType.toString(), + methodName: 'restoreSession', + ); final authentication = await withEncryptedBox( HiveBoxes.localUserCredentials, (box) { @@ -258,33 +307,48 @@ class AuthenticationCubit extends Cubit { }); if (authentication == null) { - logger.e( - "AuthenticationCubit#restoreSessionState(): Credentials could not be read!"); + logger.fe( + "Credentials could not be read!", + className: runtimeType.toString(), + methodName: 'restoreSession', + ); throw Exception( "User should be authenticated but no authentication information was found.", ); } - logger.t( - "AuthenticationCubit#restoreSessionState(): Credentials successfully retrieved."); + logger.fd( + "Credentials successfully retrieved.", + className: runtimeType.toString(), + methodName: 'restoreSession', + ); - logger.t( - "AuthenticationCubit#restoreSessionState(): Updating security context..."); + logger.fd( + "Updating security context...", + className: runtimeType.toString(), + methodName: 'restoreSession', + ); _sessionManager.updateSettings( clientCertificate: authentication.clientCertificate, authToken: authentication.token, baseUrl: localUserAccount.serverUrl, ); - logger.t( - "AuthenticationCubit#restoreSessionState(): Security context successfully updated."); + logger.fd( + "Security context successfully updated.", + className: runtimeType.toString(), + methodName: 'restoreSession', + ); final isPaperlessServerReachable = await _connectivityService.isPaperlessServerReachable( localUserAccount.serverUrl, authentication.clientCertificate, ) == ReachabilityStatus.reachable; - logger.t( - "AuthenticationCubit#restoreSessionState(): Trying to update remote paperless user..."); + logger.fd( + "Trying to update remote paperless user...", + className: runtimeType.toString(), + methodName: 'restoreSession', + ); if (isPaperlessServerReachable) { final apiVersion = await _getApiVersion(_sessionManager.client); await _updateRemoteUser( @@ -292,51 +356,83 @@ class AuthenticationCubit extends Cubit { localUserAccount, apiVersion, ); - logger.t( - "AuthenticationCubit#restoreSessionState(): Successfully updated remote paperless user."); + logger.fd( + "Successfully updated remote paperless user.", + className: runtimeType.toString(), + methodName: 'restoreSession', + ); } else { - logger.w( - "AuthenticationCubit#restoreSessionState(): Could not update remote paperless user. Server could not be reached. The app might behave unexpected!"); + 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)); - logger.t( - "AuthenticationCubit#restoreSessionState(): Previous session 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()); final globalSettings = Hive.globalSettingsBox.getValue()!; final userId = globalSettings.loggedInUserId!; - logger.t( - "AuthenticationCubit#logout(): Logging out current user ($userId)..."); + 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(); - logger.t("AuthenticationCubit#logout(): User successfully logged out."); + logger.fd( + "User successfully logged out.", + className: runtimeType.toString(), + methodName: 'logout', + ); } Future _resetExternalState() async { - logger.t( - "AuthenticationCubit#_resetExternalState(): Resetting security context..."); + logger.fd( + "Resetting security context...", + className: runtimeType.toString(), + methodName: '_resetExternalState', + ); _sessionManager.resetSettings(); - logger.t( - "AuthenticationCubit#_resetExternalState(): Security context reset."); - logger.t( - "AuthenticationCubit#_resetExternalState(): Clearing local state..."); + 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.t("AuthenticationCubit#_resetExternalState(): Local state cleard."); + logger.fd( + "Local state cleard.", + className: runtimeType.toString(), + methodName: '_resetExternalState', + ); } Future _addUser( @@ -350,8 +446,13 @@ class AuthenticationCubit extends Cubit { _FutureVoidCallback? onFetchUserInformation, }) async { assert(credentials.username != null && credentials.password != null); - logger - .t("AuthenticationCubit#_addUser(): Adding new user $localUserId...."); + final redactedId = redactUserId(localUserId); + + logger.fd( + "Adding new user $redactedId..", + className: runtimeType.toString(), + methodName: '_addUser', + ); sessionManager.updateSettings( baseUrl: serverUrl, @@ -360,8 +461,11 @@ class AuthenticationCubit extends Cubit { final authApi = _apiFactory.createAuthenticationApi(sessionManager.client); - logger.t( - "AuthenticationCubit#_addUser(): Fetching bearer token from the server..."); + logger.fd( + "Fetching bearer token from the server...", + className: runtimeType.toString(), + methodName: '_addUser', + ); await onPerformLogin?.call(); @@ -370,8 +474,11 @@ class AuthenticationCubit extends Cubit { password: credentials.password!, ); - logger.t( - "AuthenticationCubit#_addUser(): Bearer token successfully retrieved."); + logger.fd( + "Bearer token successfully retrieved.", + className: runtimeType.toString(), + methodName: '_addUser', + ); sessionManager.updateSettings( baseUrl: serverUrl, @@ -385,14 +492,20 @@ class AuthenticationCubit extends Cubit { Hive.box(HiveBoxes.localUserAppState); if (userAccountBox.containsKey(localUserId)) { - logger.w( - "AuthenticationCubit#_addUser(): 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); - logger.t( - "AuthenticationCubit#_addUser(): Trying to fetch remote paperless user for $localUserId."); + logger.fd( + "Trying to fetch remote paperless user for $redactedId.", + className: runtimeType.toString(), + methodName: '_addUser', + ); late UserModel serverUser; try { @@ -403,19 +516,27 @@ class AuthenticationCubit extends Cubit { ) .findCurrentUser(); } on DioException catch (error, stackTrace) { - logger.e( - "AuthenticationCubit#_addUser(): An error occurred while fetching the remote paperless user.", + logger.fe( + "An error occurred while fetching the remote paperless user.", + className: runtimeType.toString(), + methodName: '_addUser', error: error, stackTrace: stackTrace, ); rethrow; } - logger.t( - "AuthenticationCubit#_addUser(): Remote paperless user successfully fetched."); + logger.fd( + "Remote paperless user successfully fetched.", + className: runtimeType.toString(), + methodName: '_addUser', + ); - logger.t( - "AuthenticationCubit#_addUser(): Persisting user account information..."); + logger.fd( + "Persisting user account information...", + className: runtimeType.toString(), + methodName: '_addUser', + ); await onPersistLocalUserData?.call(); // Create user account @@ -429,20 +550,33 @@ class AuthenticationCubit extends Cubit { apiVersion: apiVersion, ), ); - logger.t( - "AuthenticationCubit#_addUser(): User account information successfully persisted."); - logger.t("AuthenticationCubit#_addUser(): Persisting user app state..."); + logger.fd( + "User account information successfully persisted.", + className: runtimeType.toString(), + methodName: '_addUser', + ); + logger.fd( + "Persisting user app state...", + className: runtimeType.toString(), + methodName: '_addUser', + ); // Create user state await userStateBox.put( localUserId, LocalUserAppState(userId: localUserId), ); - logger.t( - "AuthenticationCubit#_addUser(): User state successfully persisted."); + logger.fd( + "User state successfully persisted.", + className: runtimeType.toString(), + methodName: '_addUser', + ); // Save credentials in encrypted box await withEncryptedBox(HiveBoxes.localUserCredentials, (box) async { - logger.t( - "AuthenticationCubit#_addUser(): Saving user credentials inside encrypted storage..."); + logger.fd( + "Saving user credentials inside encrypted storage...", + className: runtimeType.toString(), + methodName: '_addUser', + ); await box.put( localUserId, @@ -451,12 +585,20 @@ class AuthenticationCubit extends Cubit { clientCertificate: clientCert, ), ); - logger.t( - "AuthenticationCubit#_addUser(): User credentials successfully saved."); + 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; @@ -467,8 +609,11 @@ class AuthenticationCubit extends Cubit { Duration? timeout, int defaultValue = 2, }) async { - logger.t( - "AuthenticationCubit#_getApiVersion(): Trying to fetch API version..."); + logger.fd( + "Trying to fetch API version...", + className: runtimeType.toString(), + methodName: '_getApiVersion', + ); try { final response = await dio.get( "/api/", @@ -478,13 +623,19 @@ class AuthenticationCubit extends Cubit { ); final apiVersion = int.parse(response.headers.value('x-api-version') ?? "3"); - logger.t( - "AuthenticationCubit#_getApiVersion(): Successfully retrieved API version ($apiVersion)."); + logger.fd( + "Successfully retrieved API version ($apiVersion).", + className: runtimeType.toString(), + methodName: '_getApiVersion', + ); return apiVersion; } on DioException catch (_) { - logger.w( - "AuthenticationCubit#_getApiVersion(): Could not retrieve API version."); + logger.fw( + "Could not retrieve API version, using default ($defaultValue).", + className: runtimeType.toString(), + methodName: '_getApiVersion', + ); return defaultValue; } } @@ -495,18 +646,21 @@ class AuthenticationCubit extends Cubit { LocalUserAccount localUserAccount, int apiVersion, ) async { - logger.t( - "AuthenticationCubit#_updateRemoteUser(): Trying to update remote 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(); - logger.t( - "AuthenticationCubit#_updateRemoteUser(): Successfully updated remote user object."); + logger.fd( + "Successfully updated remote user object.", + className: runtimeType.toString(), + methodName: '_updateRemoteUser', + ); } } 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/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..4dff64e 100644 --- a/lib/features/sharing/view/widgets/event_listener_shell.dart +++ b/lib/features/sharing/view/widgets/event_listener_shell.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/l10n/intl_ca.arb b/lib/l10n/intl_ca.arb index 2a2c013..7428f7b 100644 --- a/lib/l10n/intl_ca.arb +++ b/lib/l10n/intl_ca.arb @@ -1001,5 +1001,13 @@ "@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}" } \ No newline at end of file diff --git a/lib/l10n/intl_cs.arb b/lib/l10n/intl_cs.arb index 3267615..e151c4d 100644 --- a/lib/l10n/intl_cs.arb +++ b/lib/l10n/intl_cs.arb @@ -1001,5 +1001,13 @@ "@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}" } \ No newline at end of file diff --git a/lib/l10n/intl_de.arb b/lib/l10n/intl_de.arb index 6438a81..d902965 100644 --- a/lib/l10n/intl_de.arb +++ b/lib/l10n/intl_de.arb @@ -1001,5 +1001,13 @@ "@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" } \ No newline at end of file diff --git a/lib/l10n/intl_en.arb b/lib/l10n/intl_en.arb index 37a00a8..2bfc980 100644 --- a/lib/l10n/intl_en.arb +++ b/lib/l10n/intl_en.arb @@ -1001,5 +1001,13 @@ "@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}" } \ No newline at end of file diff --git a/lib/l10n/intl_es.arb b/lib/l10n/intl_es.arb index daa10e5..61e2c55 100644 --- a/lib/l10n/intl_es.arb +++ b/lib/l10n/intl_es.arb @@ -1001,5 +1001,13 @@ "@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}" } \ No newline at end of file diff --git a/lib/l10n/intl_fr.arb b/lib/l10n/intl_fr.arb index 8d81cb7..c605519 100644 --- a/lib/l10n/intl_fr.arb +++ b/lib/l10n/intl_fr.arb @@ -1001,5 +1001,13 @@ "@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}" } \ No newline at end of file diff --git a/lib/l10n/intl_pl.arb b/lib/l10n/intl_pl.arb index 4a6fa14..b9589fa 100644 --- a/lib/l10n/intl_pl.arb +++ b/lib/l10n/intl_pl.arb @@ -1001,5 +1001,13 @@ "@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}" } \ No newline at end of file diff --git a/lib/l10n/intl_ru.arb b/lib/l10n/intl_ru.arb index 42535d2..b6db6c0 100644 --- a/lib/l10n/intl_ru.arb +++ b/lib/l10n/intl_ru.arb @@ -1001,5 +1001,13 @@ "@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}" } \ No newline at end of file diff --git a/lib/l10n/intl_tr.arb b/lib/l10n/intl_tr.arb index a1c0e30..26935f8 100644 --- a/lib/l10n/intl_tr.arb +++ b/lib/l10n/intl_tr.arb @@ -1001,5 +1001,13 @@ "@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}" } \ No newline at end of file diff --git a/lib/main.dart b/lib/main.dart index 80c2948..16746cf 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -29,10 +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/core/logging/logger.dart'; +import 'package:paperless_mobile/core/logging/data/formatted_printer.dart'; +import 'package:paperless_mobile/core/logging/data/logger.dart'; +import 'package:paperless_mobile/core/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'; @@ -42,6 +45,7 @@ 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'; @@ -85,7 +89,11 @@ Future performMigrations() async { final requiresMigrationForCurrentVersion = !performedMigrations.contains(currentVersion); if (requiresMigrationForCurrentVersion) { - logger.t("Applying migration scripts for version $currentVersion"); + logger.fd( + "Applying migration scripts for version $currentVersion", + className: "", + methodName: "performMigrations", + ); await migrationProcedure(); await sp.setStringList( 'performed_migrations', @@ -115,7 +123,15 @@ 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, + ); Paint.enableDithering = true; + // if (kDebugMode) { // // URL: http://localhost:3131 // // Login: admin:test @@ -128,12 +144,6 @@ void main() async { // .start(); // } - logger = l.Logger( - output: MirroredFileOutput(), - printer: SpringBootLikePrinter(), - level: l.Level.trace, - ); - packageInfo = await PackageInfo.fromPlatform(); if (Platform.isAndroid) { @@ -168,7 +178,6 @@ void main() async { ); // Manages security context, required for self signed client certificates final sessionManager = SessionManager([ - languageHeaderInterceptor, PrettyDioLogger( compact: true, responseBody: false, @@ -178,6 +187,7 @@ void main() async { requestHeader: false, logPrint: (object) => logger.t, ), + languageHeaderInterceptor, ]); // Initialize Blocs/Cubits @@ -225,14 +235,19 @@ void main() async { ), ), ); - }, (error, stack) { + }, (error, stackTrace) { // 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 }; - logger.e(message, stackTrace: stack); + logger.fe( + "An unexpected error occurred${message != null ? "- $message" : ""}", + error: message == null ? error : null, + methodName: "main", + stackTrace: stackTrace, + ); }); } @@ -270,7 +285,7 @@ class _GoRouterShellState extends State { final DisplayMode mostOptimalMode = sameResolution.isNotEmpty ? sameResolution.first : active; - logger.d('Setting refresh rate to ${mostOptimalMode.refreshRate}'); + logger.fi('Setting refresh rate to ${mostOptimalMode.refreshRate}'); await FlutterDisplayMode.setPreferredMode(mostOptimalMode); } @@ -336,6 +351,7 @@ class _GoRouterShellState extends State { $loggingOutRoute, $addAccountRoute, $changelogRoute, + $appLogsRoute, $authenticatedRoute, ], ), 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..dd7c2ae --- /dev/null +++ b/lib/routes/typed/top_level/app_logs_route.dart @@ -0,0 +1,26 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:go_router/go_router.dart'; +import 'package:paperless_mobile/core/logging/cubit/app_logs_cubit.dart'; +import 'package:paperless_mobile/core/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())..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/pubspec.lock b/pubspec.lock index a13759c..a4f0034 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -1657,7 +1657,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 20a9a79..b8363f6 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -99,6 +99,7 @@ dependencies: shared_preferences: ^2.2.1 flutter_markdown: ^0.6.18 logger: ^2.0.2+1 + synchronized: ^3.1.0 # camerawesome: ^2.0.0-dev.1 dependency_overrides: From 520bfbd7b14fa7d0c2c4d37637a054484639daef Mon Sep 17 00:00:00 2001 From: Anton Stubenbord Date: Thu, 19 Oct 2023 18:26:02 +0200 Subject: [PATCH 3/6] fix: Enable logging in production --- android/app/build.gradle | 4 + .../custom_adapters/theme_mode_adapter.dart | 2 +- .../hive/hive_config.dart | 2 +- .../hive/hive_extensions.dart | 2 +- lib/core/database/tables/global_settings.dart | 2 +- .../database/tables/local_user_account.dart | 2 +- .../database/tables/local_user_app_state.dart | 2 +- .../database/tables/local_user_settings.dart | 2 +- .../database/tables/user_credentials.dart | 2 +- .../extensions/dart_extensions.dart | 0 .../document_iterable_extensions.dart | 18 ++ .../extensions/flutter_extensions.dart | 0 lib/core/service/file_service.dart | 6 +- lib/core/service/github_issue_service.dart | 2 +- lib/core/widgets/error_report_page.dart | 2 +- .../extended_date_range_dialog.dart | 2 +- .../form_builder_localized_date_picker.dart | 285 ++++++++++++++++++ .../fullscreen_selection_form.dart | 2 +- lib/core/widgets/hint_card.dart | 2 +- lib/core/widgets/paperless_logo.dart | 41 --- lib/features/app_drawer/view/app_drawer.dart | 13 +- .../changelogs/view/changelog_dialog.dart | 2 +- .../fullscreen_bulk_edit_label_page.dart | 2 +- .../fullscreen_bulk_edit_tags_widget.dart | 2 +- .../cubit/document_details_cubit.dart | 18 +- .../view/pages/document_details_page.dart | 2 +- .../widgets/archive_serial_number_field.dart | 2 +- .../view/widgets/document_content_widget.dart | 2 +- .../widgets/document_download_button.dart | 4 +- .../widgets/document_meta_data_widget.dart | 2 +- .../widgets/document_overview_widget.dart | 2 +- .../view/widgets/document_share_button.dart | 4 +- .../cubit/document_edit_cubit.dart | 10 +- .../view/document_edit_page.dart | 20 +- .../cubit/document_scanner_cubit.dart | 2 +- .../document_scan/view/scanner_page.dart | 2 +- .../view/document_search_bar.dart | 2 +- .../view/document_search_page.dart | 2 +- .../view/sliver_search_bar.dart | 2 +- .../document_upload_preparation_page.dart | 6 +- .../documents/cubit/documents_cubit.dart | 10 +- .../documents/view/pages/documents_page.dart | 2 +- .../view/widgets/documents_empty_state.dart | 2 +- .../widgets/items/document_detailed_item.dart | 4 +- .../document_grid_loading_widget.dart | 2 +- .../widgets/placeholder/tags_placeholder.dart | 2 +- .../widgets/saved_views/saved_view_chip.dart | 2 +- .../saved_views/saved_views_widget.dart | 2 +- .../sort_field_selection_bottom_sheet.dart | 2 +- .../document_selection_sliver_app_bar.dart | 2 +- lib/features/edit_label/view/label_form.dart | 2 +- lib/features/home/view/home_shell_widget.dart | 4 +- lib/features/inbox/cubit/inbox_cubit.dart | 2 +- lib/features/inbox/view/pages/inbox_page.dart | 4 +- .../inbox/view/widgets/inbox_item.dart | 2 +- .../view/widgets/fullscreen_tags_form.dart | 2 +- .../tags/view/widgets/tags_form_field.dart | 2 +- .../labels/view/pages/labels_page.dart | 4 +- .../view/widgets/fullscreen_label_form.dart | 2 +- .../labels/view/widgets/label_form_field.dart | 2 +- .../labels/view/widgets/label_tab_view.dart | 2 +- lib/features/landing/view/landing_page.dart | 2 +- .../logging/cubit/app_logs_cubit.dart | 62 ++-- .../logging/cubit/app_logs_state.dart | 0 .../logging/data/formatted_printer.dart | 3 +- .../logging/data/logger.dart | 2 +- .../logging/data/mirrored_file_output.dart | 14 +- .../logging/models/formatted_log_message.dart | 0 .../logging/models/parsed_log_message.dart | 3 +- .../logging/utils/redaction_utils.dart | 0 .../logging/view/app_logs_page.dart | 13 +- .../login/cubit/authentication_cubit.dart | 9 +- .../model/authentication_information.dart | 2 +- .../login/model/client_certificate.dart | 2 +- lib/features/login/view/add_account_page.dart | 2 +- lib/features/login/view/login_page.dart | 4 +- .../view/login_to_existing_account_page.dart | 2 +- .../login/view/verify_identity_page.dart | 2 +- .../client_certificate_form_field.dart | 2 +- .../server_address_form_field.dart | 2 +- .../user_credentials_form_field.dart | 4 +- .../view/widgets/login_transition_page.dart | 2 +- .../notification_tap_response_payload.dart | 6 +- .../models/notification_actions.dart | 2 +- .../models/notification_channels.dart | 3 +- ...rectory_notification_response_payload.dart | 22 ++ .../open_downloaded_document_payload.dart | 19 -- .../services/local_notification_service.dart | 40 ++- .../cubit/saved_view_preview_cubit.dart | 51 +++- .../view/saved_view_preview.dart | 3 +- .../settings/model/color_scheme_option.dart | 2 +- .../settings/model/file_download_type.dart | 2 +- lib/features/settings/model/view_type.dart | 2 +- .../settings/view/manage_accounts_page.dart | 2 +- .../view/widgets/global_settings_builder.dart | 2 +- .../view/widgets/user_settings_builder.dart | 2 +- .../view/widgets/event_listener_shell.dart | 4 +- lib/main.dart | 21 +- .../typed/shells/authenticated_route.dart | 2 +- .../typed/shells/scaffold_shell_route.dart | 2 +- .../typed/top_level/app_logs_route.dart | 10 +- lib/routes/typed/top_level/login_route.dart | 2 +- pubspec.lock | 16 +- pubspec.yaml | 3 +- 104 files changed, 632 insertions(+), 257 deletions(-) rename lib/core/{config => database}/hive/custom_adapters/theme_mode_adapter.dart (93%) rename lib/core/{config => database}/hive/hive_config.dart (96%) rename lib/core/{config => database}/hive/hive_extensions.dart (96%) rename lib/{ => core}/extensions/dart_extensions.dart (100%) create mode 100644 lib/core/extensions/document_iterable_extensions.dart rename lib/{ => core}/extensions/flutter_extensions.dart (100%) create mode 100644 lib/core/widgets/form_builder_fields/form_builder_localized_date_picker.dart delete mode 100644 lib/core/widgets/paperless_logo.dart rename lib/{core => features}/logging/cubit/app_logs_cubit.dart (60%) rename lib/{core => features}/logging/cubit/app_logs_state.dart (100%) rename lib/{core => features}/logging/data/formatted_printer.dart (91%) rename lib/{core => features}/logging/data/logger.dart (96%) rename lib/{core => features}/logging/data/mirrored_file_output.dart (82%) rename lib/{core => features}/logging/models/formatted_log_message.dart (100%) rename lib/{core => features}/logging/models/parsed_log_message.dart (98%) rename lib/{core => features}/logging/utils/redaction_utils.dart (100%) rename lib/{core => features}/logging/view/app_logs_page.dart (95%) create mode 100644 lib/features/notifications/models/notification_payloads/notification_tap/open_directory_notification_response_payload.dart delete mode 100644 lib/features/notifications/models/notification_payloads/notification_tap/open_downloaded_document_payload.dart 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/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 100% rename from lib/extensions/dart_extensions.dart rename to lib/core/extensions/dart_extensions.dart diff --git a/lib/core/extensions/document_iterable_extensions.dart b/lib/core/extensions/document_iterable_extensions.dart new file mode 100644 index 0000000..0bb9718 --- /dev/null +++ b/lib/core/extensions/document_iterable_extensions.dart @@ -0,0 +1,18 @@ +import 'package:collection/collection.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); + } +} 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/service/file_service.dart b/lib/core/service/file_service.dart index 83d0ebe..2dfa6a3 100644 --- a/lib/core/service/file_service.dart +++ b/lib/core/service/file_service.dart @@ -1,8 +1,8 @@ import 'dart:io'; import 'package:flutter/foundation.dart'; -import 'package:paperless_mobile/core/logging/data/logger.dart'; -import 'package:paperless_mobile/core/logging/utils/redaction_utils.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'; @@ -231,9 +231,9 @@ class FileService { type: StorageDirectory.downloads, ); directory = await downloadsDir!.first.create(recursive: true); - return; } _downloadsDirectory = directory; + return; } else if (Platform.isIOS) { final appDir = await getApplicationDocumentsDirectory(); final dir = Directory('${appDir.path}/downloads'); 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..369db47 --- /dev/null +++ b/lib/core/widgets/form_builder_fields/form_builder_localized_date_picker.dart @@ -0,0 +1,285 @@ +import 'dart:collection'; +import 'dart:math'; + +import 'package:collection/collection.dart'; +import 'package:extended_masked_text/extended_masked_text.dart'; +import 'package:flutter/foundation.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:synchronized/extension.dart'; + +final class NeighbourAwareDateInputSegmentControls + with LinkedListEntry { + final FocusNode node; + final TextEditingController controller; + final int position; + final String format; + final DateTime? initialDate; + + NeighbourAwareDateInputSegmentControls({ + required this.node, + required this.controller, + required this.format, + this.initialDate, + required this.position, + }); +} + +class FormBuilderLocalizedDatePicker extends StatefulWidget { + final String name; + final String labelText; + final Widget? prefixIcon; + final DateTime? initialValue; + final DateTime firstDate; + final DateTime lastDate; + final Locale locale; + + const FormBuilderLocalizedDatePicker({ + super.key, + required this.name, + this.initialValue, + required this.firstDate, + required this.lastDate, + required this.locale, + required this.labelText, + this.prefixIcon, + }); + + @override + State createState() => + _FormBuilderLocalizedDatePickerState(); +} + +class _FormBuilderLocalizedDatePickerState + extends State { + late final String _separator; + late final String _format; + + final _textFieldControls = + LinkedList(); + + @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 item = NeighbourAwareDateInputSegmentControls( + node: FocusNode(debugLabel: formatString), + controller: TextEditingController(text: initialText), + format: formatString, + position: i, + ); + item.controller.addListener(() { + if (item.controller.text.length == item.format.length) { + // _textFieldControls.elementAt(i).next?.node.requestFocus(); + // _textFieldControls.elementAt(i).next?.controller.selection = + // const TextSelection.collapsed(offset: 0); + // return; + } + }); + item.node.addListener(() { + if (item.node.hasFocus) { + item.controller.selection = const TextSelection.collapsed(offset: 0); + } + }); + _textFieldControls.add(item); + } + } + + @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, + initialValue: widget.initialValue, + validator: (value) { + if (value?.isBefore(widget.firstDate) ?? false) { + return "Date must be before " + + DateFormat.yMd(widget.locale.toString()) + .format(widget.firstDate); + } + if (value?.isAfter(widget.lastDate) ?? false) { + return "Date must be after " + + DateFormat.yMd(widget.locale.toString()) + .format(widget.lastDate); + } + return null; + }, + builder: (field) { + return SizedBox( + height: 56, + child: InputDecorator( + textAlignVertical: TextAlignVertical.bottom, + decoration: InputDecoration( + labelText: widget.labelText, + prefixIcon: widget.prefixIcon, + suffixIcon: Row( + mainAxisSize: MainAxisSize.min, + children: [ + IconButton( + icon: const Icon(Icons.calendar_month_outlined), + onPressed: () async { + final selectedDate = await showDatePicker( + context: context, + initialDate: widget.initialValue ?? DateTime.now(), + firstDate: widget.firstDate, + lastDate: widget.lastDate, + initialEntryMode: DatePickerEntryMode.calendarOnly, + ); + if (selectedDate != null) { + _updateInputsWithDate(selectedDate); + field.didChange(selectedDate); + FocusScope.of(context).unfocus(); + } + }, + ), + IconButton( + onPressed: () { + field.didChange(null); + for (var c in _textFieldControls) { + c.controller.clear(); + } + _textFieldControls.first.node.requestFocus(); + }, + icon: const Icon(Icons.clear), + ), + ], + ).paddedOnly(right: 4), + ), + child: Row( + children: [ + for (var s in _textFieldControls) ...[ + SizedBox( + width: switch (s.format) { + == "dd" => 32, + == "MM" => 32, + == "yyyy" => 48, + _ => 0, + }, + child: _buildDateSegmentInput(s, context, field), + ), + if (s.position < 2) Text(_separator).paddedOnly(right: 4), + ], + ], + ), + ), + ); + }, + ), + ); + } + + void _updateInputsWithDate(DateTime date) { + final components = _format.split(_separator); + for (int i = 0; i < components.length; i++) { + final formatString = components[i]; + final value = DateFormat(formatString).format(date); + _textFieldControls.elementAt(i).controller.text = value; + } + } + + Widget _buildDateSegmentInput( + NeighbourAwareDateInputSegmentControls controls, + BuildContext context, + FormFieldState field, + ) { + return TextFormField( + onFieldSubmitted: (value) { + _textFieldControls + .elementAt(controls.position) + .next + ?.node + .requestFocus(); + }, + // onTap: () { + // controls.controller.clear(); + // }, + canRequestFocus: true, + keyboardType: TextInputType.datetime, + textInputAction: TextInputAction.done, + controller: controls.controller, + focusNode: _textFieldControls.elementAt(controls.position).node, + maxLength: controls.format.length, + maxLengthEnforcement: MaxLengthEnforcement.truncateAfterCompositionEnds, + enableInteractiveSelection: false, + inputFormatters: [ + FilteringTextInputFormatter.digitsOnly, + ReplacingTextFormatter(), + ], + decoration: InputDecoration( + isDense: true, + contentPadding: EdgeInsets.zero, + counterText: '', + hintText: controls.format, + border: Theme.of(context).inputDecorationTheme.border?.copyWith( + borderSide: const BorderSide( + width: 0, + style: BorderStyle.none, + ), + ), + ), + ); + } +} + +class ReplacingTextFormatter extends TextInputFormatter { + @override + TextEditingValue formatEditUpdate( + TextEditingValue oldValue, + TextEditingValue newValue, + ) { + final oldOffset = oldValue.selection.baseOffset; + final newOffset = newValue.selection.baseOffset; + final replacement = newValue.text.substring(oldOffset, newOffset); + print( + "DBG: Received ${oldValue.text} -> ${newValue.text}. New char = $replacement"); + if (oldOffset < newOffset) { + final oldText = oldValue.text; + final newText = oldText.replaceRange( + oldOffset, + newOffset, + newValue.text.substring(oldOffset, newOffset), + ); + print("DBG: Replacing $oldText -> $newText"); + return newValue.copyWith( + text: newText, + selection: TextSelection.collapsed(offset: newOffset), + ); + } + + 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 f569109..e679bdd 100644 --- a/lib/features/app_drawer/view/app_drawer.dart +++ b/lib/features/app_drawer/view/app_drawer.dart @@ -4,9 +4,8 @@ import 'package:flutter_animate/flutter_animate.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:paperless_mobile/constants.dart'; import 'package:paperless_mobile/core/database/tables/local_user_account.dart'; -import 'package:paperless_mobile/core/logging/view/app_logs_page.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'; @@ -38,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, diff --git a/lib/features/changelogs/view/changelog_dialog.dart b/lib/features/changelogs/view/changelog_dialog.dart index 8aa6fec..7ebfa9e 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'; 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 f28ff2b..eae2199 100644 --- a/lib/features/document_details/cubit/document_details_cubit.dart +++ b/lib/features/document_details/cubit/document_details_cubit.dart @@ -6,7 +6,7 @@ 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/logging/data/logger.dart'; +import 'package:paperless_mobile/features/logging/data/logger.dart'; import 'package:paperless_mobile/core/notifier/document_changed_notifier.dart'; import 'package:paperless_mobile/core/repository/label_repository.dart'; import 'package:paperless_mobile/core/service/file_service.dart'; @@ -30,10 +30,12 @@ class DocumentDetailsCubit extends Cubit { this._notifier, this._notificationService, { required DocumentModel initialDocument, - }) : super(DocumentDetailsState( - document: initialDocument, - )) { - _notifier.addListener(this, onUpdated: replace); + }) : super(DocumentDetailsState(document: initialDocument)) { + _notifier.addListener(this, onUpdated: (document) { + if (document.id == state.document.id) { + replace(document); + } + }); _labelRepository.addListener( this, onChanged: (labels) => emit( @@ -127,7 +129,7 @@ class DocumentDetailsCubit extends Cubit { if (!await File(targetPath).exists()) { await File(targetPath).create(); } else { - await _notificationService.notifyFileDownload( + await _notificationService.notifyDocumentDownload( document: state.document, filename: p.basename(targetPath), filePath: targetPath, @@ -151,7 +153,7 @@ class DocumentDetailsCubit extends Cubit { targetPath, original: downloadOriginal, onProgressChanged: (progress) { - _notificationService.notifyFileDownload( + _notificationService.notifyDocumentDownload( document: state.document, filename: p.basename(targetPath), filePath: targetPath, @@ -162,7 +164,7 @@ class DocumentDetailsCubit extends Cubit { ); }, ); - await _notificationService.notifyFileDownload( + await _notificationService.notifyDocumentDownload( document: state.document, filename: p.basename(targetPath), filePath: targetPath, 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..9225d8c 100644 --- a/lib/features/document_details/view/pages/document_details_page.dart +++ b/lib/features/document_details/view/pages/document_details_page.dart @@ -9,7 +9,7 @@ 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/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/core/extensions/flutter_extensions.dart'; import 'package:paperless_mobile/features/document_details/cubit/document_details_cubit.dart'; import 'package:paperless_mobile/features/document_details/view/widgets/document_content_widget.dart'; import 'package:paperless_mobile/features/document_details/view/widgets/document_download_button.dart'; 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..3aeaef7 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'; 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..a73dec3 100644 --- a/lib/features/document_details/view/widgets/document_content_widget.dart +++ b/lib/features/document_details/view/widgets/document_content_widget.dart @@ -3,7 +3,7 @@ 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/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'; 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..0d003ff 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,7 +3,7 @@ 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/core/extensions/flutter_extensions.dart'; import 'package:paperless_mobile/features/document_details/cubit/document_details_cubit.dart'; import 'package:paperless_mobile/features/document_details/view/widgets/archive_serial_number_field.dart'; import 'package:paperless_mobile/features/document_details/view/widgets/details_item.dart'; 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..d15d0c8 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,7 @@ 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/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'; 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..d6f28d2 100644 --- a/lib/features/document_edit/cubit/document_edit_cubit.dart +++ b/lib/features/document_edit/cubit/document_edit_cubit.dart @@ -22,7 +22,11 @@ class DocumentEditCubit extends Cubit { required DocumentModel document, }) : _initialDocument = document, super(DocumentEditState(document: document)) { - _notifier.addListener(this, onUpdated: replace); + _notifier.addListener(this, onUpdated: (doc) { + if (doc.id == document.id) { + emit(state.copyWith(document: doc)); + } + }); _labelRepository.addListener( this, onChanged: (labels) { @@ -69,10 +73,6 @@ class DocumentEditCubit extends Cubit { emit(state.copyWith(suggestions: suggestions)); } - void replace(DocumentModel document) { - emit(state.copyWith(document: document)); - } - @override Future close() { _notifier.removeListener(this); diff --git a/lib/features/document_edit/view/document_edit_page.dart b/lib/features/document_edit/view/document_edit_page.dart index 2b51c97..b486c87 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'; @@ -12,8 +11,9 @@ 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/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/core/extensions/flutter_extensions.dart'; import 'package:paperless_mobile/features/document_edit/cubit/document_edit_cubit.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'; @@ -21,7 +21,6 @@ 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({ @@ -401,6 +400,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, ); @@ -408,6 +413,15 @@ class _DocumentEditPageState extends State { Widget _buildCreatedAtFormField( DateTime? initialCreatedAtDate, FieldSuggestions? filteredSuggestions) { + // return FormBuilderLocalizedDatePicker( + // name: fkCreatedDate, + // initialValue: initialCreatedAtDate, + // labelText: S.of(context)!.createdAt, + // firstDate: DateTime(1970, 1, 1), + // lastDate: DateTime.now(), + // locale: Localizations.localeOf(context), + // prefixIcon: Icon(Icons.calendar_today), + // ); return Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ diff --git a/lib/features/document_scan/cubit/document_scanner_cubit.dart b/lib/features/document_scan/cubit/document_scanner_cubit.dart index 364ce7f..85b63d7 100644 --- a/lib/features/document_scan/cubit/document_scanner_cubit.dart +++ b/lib/features/document_scan/cubit/document_scanner_cubit.dart @@ -4,7 +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/core/logging/data/logger.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'; diff --git a/lib/features/document_scan/view/scanner_page.dart b/lib/features/document_scan/view/scanner_page.dart index 316c3a2..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'; 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..5f8c6bb 100644 --- a/lib/features/document_search/view/document_search_page.dart +++ b/lib/features/document_search/view/document_search_page.dart @@ -4,7 +4,7 @@ import 'dart:math' as math; import 'package:collection/collection.dart'; import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; -import 'package:paperless_mobile/extensions/flutter_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'; 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/view/document_upload_preparation_page.dart b/lib/features/document_upload/view/document_upload_preparation_page.dart index bd0d8d5..959d914 100644 --- a/lib/features/document_upload/view/document_upload_preparation_page.dart +++ b/lib/features/document_upload/view/document_upload_preparation_page.dart @@ -9,13 +9,13 @@ 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/logging/data/logger.dart'; +import 'package:paperless_mobile/features/logging/data/logger.dart'; import 'package:paperless_mobile/core/repository/label_repository.dart'; import 'package:paperless_mobile/core/widgets/future_or_builder.dart'; -import 'package:paperless_mobile/extensions/flutter_extensions.dart'; +import 'package:paperless_mobile/core/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'; diff --git a/lib/features/documents/cubit/documents_cubit.dart b/lib/features/documents/cubit/documents_cubit.dart index de482c9..3cf8716 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_iterable_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(), ), ); }, diff --git a/lib/features/documents/view/pages/documents_page.dart b/lib/features/documents/view/pages/documents_page.dart index 0f31032..ce3f8e9 100644 --- a/lib/features/documents/view/pages/documents_page.dart +++ b/lib/features/documents/view/pages/documents_page.dart @@ -6,7 +6,7 @@ import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:paperless_api/paperless_api.dart'; import 'package:paperless_mobile/core/bloc/connectivity_cubit.dart'; import 'package:paperless_mobile/core/database/tables/local_user_account.dart'; -import 'package:paperless_mobile/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/documents/cubit/documents_cubit.dart'; 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..b0388bd 100644 --- a/lib/features/documents/view/widgets/items/document_detailed_item.dart +++ b/lib/features/documents/view/widgets/items/document_detailed_item.dart @@ -5,11 +5,11 @@ 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/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'; 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 b67f012..68de175 100644 --- a/lib/features/inbox/cubit/inbox_cubit.dart +++ b/lib/features/inbox/cubit/inbox_cubit.dart @@ -3,7 +3,7 @@ import 'dart:async'; 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/logging/data/logger.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'; 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..aa2ee52 100644 --- a/lib/features/inbox/view/widgets/inbox_item.dart +++ b/lib/features/inbox/view/widgets/inbox_item.dart @@ -5,7 +5,7 @@ import 'package:paperless_api/paperless_api.dart'; import 'package:paperless_mobile/core/database/tables/local_user_account.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'; 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 7f0b71b..1fbcdf3 100644 --- a/lib/features/labels/view/pages/labels_page.dart +++ b/lib/features/labels/view/pages/labels_page.dart @@ -3,11 +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/core/logging/data/logger.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'; 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 08d31b1..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'; diff --git a/lib/core/logging/cubit/app_logs_cubit.dart b/lib/features/logging/cubit/app_logs_cubit.dart similarity index 60% rename from lib/core/logging/cubit/app_logs_cubit.dart rename to lib/features/logging/cubit/app_logs_cubit.dart index 7ff127a..6ec2eb1 100644 --- a/lib/core/logging/cubit/app_logs_cubit.dart +++ b/lib/features/logging/cubit/app_logs_cubit.dart @@ -6,9 +6,11 @@ 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/core/logging/models/parsed_log_message.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'; @@ -16,7 +18,11 @@ final _fileNameFormat = DateFormat("yyyy-MM-dd"); class AppLogsCubit extends Cubit { StreamSubscription? _fileChangesSubscription; - AppLogsCubit(DateTime date) : super(AppLogsStateInitial(date: date)); + final LocalNotificationService _localNotificationService; + AppLogsCubit( + DateTime date, + this._localNotificationService, + ) : super(AppLogsStateInitial(date: date)); Future loadLogs(DateTime date) async { if (date == state.date) { @@ -42,24 +48,12 @@ class AppLogsCubit extends Cubit { )); } try { - final logs = await logFile.readAsLines(); - final parsedLogs = - ParsedLogMessage.parse(logs.skip(2000).toList()).reversed.toList(); + _updateLogsFromFile(logFile, date, availableLogs); _fileChangesSubscription = logFile.watch().listen((event) async { if (!isClosed) { - final logs = await logFile.readAsLines(); - emit(AppLogsStateLoaded( - date: date, - logs: parsedLogs, - availableLogs: availableLogs, - )); + _updateLogsFromFile(logFile, date, availableLogs); } }); - emit(AppLogsStateLoaded( - date: date, - logs: parsedLogs, - availableLogs: availableLogs, - )); } catch (e) { emit(AppLogsStateError( error: e, @@ -68,6 +62,17 @@ class AppLogsCubit extends Cubit { } } + 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(''); @@ -86,16 +91,19 @@ class AppLogsCubit extends Cubit { 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, - ); + // 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); - if (parentDir != null) { - await logFile.copy(p.join(parentDir, filename)); - } + final parentDir = FileService.instance.downloadsDirectory; + final downloadedFile = await logFile.copy(p.join(parentDir.path, filename)); + _localNotificationService.notifyFileDownload(filePath: downloadedFile.path); } File _getLogfile(DateTime date) { @@ -104,8 +112,8 @@ class AppLogsCubit extends Cubit { } @override - Future close() { - _fileChangesSubscription?.cancel(); + Future close() async { + await _fileChangesSubscription?.cancel(); return super.close(); } } diff --git a/lib/core/logging/cubit/app_logs_state.dart b/lib/features/logging/cubit/app_logs_state.dart similarity index 100% rename from lib/core/logging/cubit/app_logs_state.dart rename to lib/features/logging/cubit/app_logs_state.dart diff --git a/lib/core/logging/data/formatted_printer.dart b/lib/features/logging/data/formatted_printer.dart similarity index 91% rename from lib/core/logging/data/formatted_printer.dart rename to lib/features/logging/data/formatted_printer.dart index 63d88c2..c2faec9 100644 --- a/lib/core/logging/data/formatted_printer.dart +++ b/lib/features/logging/data/formatted_printer.dart @@ -1,9 +1,10 @@ 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/core/logging/models/formatted_log_message.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"); diff --git a/lib/core/logging/data/logger.dart b/lib/features/logging/data/logger.dart similarity index 96% rename from lib/core/logging/data/logger.dart rename to lib/features/logging/data/logger.dart index e7016be..d254237 100644 --- a/lib/core/logging/data/logger.dart +++ b/lib/features/logging/data/logger.dart @@ -1,5 +1,5 @@ import 'package:logger/logger.dart'; -import 'package:paperless_mobile/core/logging/models/formatted_log_message.dart'; +import 'package:paperless_mobile/features/logging/models/formatted_log_message.dart'; late Logger logger; diff --git a/lib/core/logging/data/mirrored_file_output.dart b/lib/features/logging/data/mirrored_file_output.dart similarity index 82% rename from lib/core/logging/data/mirrored_file_output.dart rename to lib/features/logging/data/mirrored_file_output.dart index 8feff46..01cef24 100644 --- a/lib/core/logging/data/mirrored_file_output.dart +++ b/lib/features/logging/data/mirrored_file_output.dart @@ -9,8 +9,9 @@ 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 { - final Completer _initCompleter = Completer(); var lock = Lock(); MirroredFileOutput(); @@ -22,7 +23,6 @@ class MirroredFileOutput extends LogOutput { final logDir = FileService.instance.logDirectory; file = File(p.join(logDir.path, '$today.log')); debugPrint("Logging files to ${file.path}."); - _initCompleter.complete(); try { final oldLogs = await FileService.instance.getAllFiles(logDir); if (oldLogs.length > 10) { @@ -42,12 +42,10 @@ class MirroredFileOutput extends LogOutput { await lock.synchronized(() async { for (var line in event.lines) { debugPrint(line); - if (_initCompleter.isCompleted) { - await file.writeAsString( - "$line${Platform.lineTerminator}", - mode: FileMode.append, - ); - } + await file.writeAsString( + "$line${Platform.lineTerminator}", + mode: FileMode.append, + ); } }); } diff --git a/lib/core/logging/models/formatted_log_message.dart b/lib/features/logging/models/formatted_log_message.dart similarity index 100% rename from lib/core/logging/models/formatted_log_message.dart rename to lib/features/logging/models/formatted_log_message.dart diff --git a/lib/core/logging/models/parsed_log_message.dart b/lib/features/logging/models/parsed_log_message.dart similarity index 98% rename from lib/core/logging/models/parsed_log_message.dart rename to lib/features/logging/models/parsed_log_message.dart index 3c121cc..d63a591 100644 --- a/lib/core/logging/models/parsed_log_message.dart +++ b/lib/features/logging/models/parsed_log_message.dart @@ -40,12 +40,13 @@ class ParsedErrorLogMessage { static bool canConsumeFirstLine(String line) => _errorBeginPattern.hasMatch(line); - static (int consumedLines, ParsedErrorLogMessage result) consume( + 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++; diff --git a/lib/core/logging/utils/redaction_utils.dart b/lib/features/logging/utils/redaction_utils.dart similarity index 100% rename from lib/core/logging/utils/redaction_utils.dart rename to lib/features/logging/utils/redaction_utils.dart diff --git a/lib/core/logging/view/app_logs_page.dart b/lib/features/logging/view/app_logs_page.dart similarity index 95% rename from lib/core/logging/view/app_logs_page.dart rename to lib/features/logging/view/app_logs_page.dart index 0ba82ce..f8fef47 100644 --- a/lib/core/logging/view/app_logs_page.dart +++ b/lib/features/logging/view/app_logs_page.dart @@ -3,10 +3,10 @@ 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/core/logging/cubit/app_logs_cubit.dart'; -import 'package:paperless_mobile/core/logging/models/parsed_log_message.dart'; -import 'package:paperless_mobile/extensions/dart_extensions.dart'; -import 'package:paperless_mobile/extensions/flutter_extensions.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 { @@ -134,9 +134,8 @@ class _AppLogsPageState extends State { }, ), AppLogsStateError() => Center( - child: Text( - S.of(context)!.couldNotLoadLogfileFrom(formattedDate), - ), + child: + Text(S.of(context)!.couldNotLoadLogfileFrom(formattedDate)), ), _ => _buildLoadingLogs(state.date) }, diff --git a/lib/features/login/cubit/authentication_cubit.dart b/lib/features/login/cubit/authentication_cubit.dart index f59223c..6f3993d 100644 --- a/lib/features/login/cubit/authentication_cubit.dart +++ b/lib/features/login/cubit/authentication_cubit.dart @@ -1,12 +1,11 @@ import 'package:dio/dio.dart'; import 'package:equatable/equatable.dart'; -import 'package:flutter/foundation.dart'; 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'; @@ -14,8 +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/core/logging/data/logger.dart'; -import 'package:paperless_mobile/core/logging/utils/redaction_utils.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'; 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..bf450be 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'; 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..e96d7d6 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'; 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/saved_view_details/cubit/saved_view_preview_cubit.dart b/lib/features/saved_view_details/cubit/saved_view_preview_cubit.dart index 2bd8fcd..e762e45 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_iterable_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..49f5ff9 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,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/items/document_list_item.dart'; import 'package:paperless_mobile/features/landing/view/widgets/expansion_card.dart'; @@ -24,6 +24,7 @@ class SavedViewPreview extends StatelessWidget { Widget build(BuildContext context) { return Provider( create: (context) => SavedViewPreviewCubit( + context.read(), context.read(), context.read(), view: savedView, 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/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/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/view/widgets/event_listener_shell.dart b/lib/features/sharing/view/widgets/event_listener_shell.dart index 4dff64e..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'; diff --git a/lib/main.dart b/lib/main.dart index 16746cf..ce8b75b 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -21,7 +21,7 @@ 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'; @@ -29,9 +29,9 @@ import 'package:paperless_mobile/core/exception/server_message_exception.dart'; import 'package:paperless_mobile/core/factory/paperless_api_factory.dart'; import 'package:paperless_mobile/core/factory/paperless_api_factory_impl.dart'; import 'package:paperless_mobile/core/interceptor/language_header.interceptor.dart'; -import 'package:paperless_mobile/core/logging/data/formatted_printer.dart'; -import 'package:paperless_mobile/core/logging/data/logger.dart'; -import 'package:paperless_mobile/core/logging/data/mirrored_file_output.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'; @@ -129,6 +129,7 @@ void main() async { output: MirroredFileOutput(), printer: FormattedPrinter(), level: l.Level.trace, + filter: l.ProductionFilter(), ); Paint.enableDithering = true; @@ -236,6 +237,12 @@ void main() async { ), ); }, (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. final message = switch (error) { PaperlessApiException e => e.details ?? error.toString(), @@ -332,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; } }, diff --git a/lib/routes/typed/shells/authenticated_route.dart b/lib/routes/typed/shells/authenticated_route.dart index b1a2ebc..5ad17f4 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'; 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 index dd7c2ae..75121ad 100644 --- a/lib/routes/typed/top_level/app_logs_route.dart +++ b/lib/routes/typed/top_level/app_logs_route.dart @@ -1,8 +1,8 @@ import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:go_router/go_router.dart'; -import 'package:paperless_mobile/core/logging/cubit/app_logs_cubit.dart'; -import 'package:paperless_mobile/core/logging/view/app_logs_page.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'; @@ -17,8 +17,10 @@ class AppLogsRoute extends GoRouteData { return AnnotatedRegion( value: buildOverlayStyle(Theme.of(context)), child: BlocProvider( - create: (context) => - AppLogsCubit(DateTime.now())..loadLogs(DateTime.now()), + create: (context) => AppLogsCubit( + DateTime.now(), + context.read(), + )..loadLogs(DateTime.now()), child: AppLogsPage(key: state.pageKey), ), ); 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/pubspec.lock b/pubspec.lock index a4f0034..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: @@ -1223,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: diff --git a/pubspec.yaml b/pubspec.yaml index b8363f6..129123b 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -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: @@ -100,6 +100,7 @@ dependencies: 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: From 18e178b6443e43f3bb3875fd3a9bb2e400946e10 Mon Sep 17 00:00:00 2001 From: Anton Stubenbord Date: Fri, 20 Oct 2023 01:24:41 +0200 Subject: [PATCH 4/6] feat: Add improved date input --- assets/fonts/RobotoMono-Regular.ttf | Bin 0 -> 87236 bytes .../form_builder_localized_date_picker.dart | 264 ++++++++++++------ lib/features/app_drawer/view/app_drawer.dart | 2 +- .../view/document_edit_page.dart | 24 +- .../local_date_time_json_converter.dart | 3 +- pubspec.yaml | 30 +- 6 files changed, 197 insertions(+), 126 deletions(-) create mode 100644 assets/fonts/RobotoMono-Regular.ttf diff --git a/assets/fonts/RobotoMono-Regular.ttf b/assets/fonts/RobotoMono-Regular.ttf new file mode 100644 index 0000000000000000000000000000000000000000..6df2b253603094de7f39886aae03181c686e375b GIT binary patch literal 87236 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 WiD&}1U9w};%2Kl9!gJKh^8W$jCY;;= literal 0 HcmV?d00001 diff --git a/lib/core/widgets/form_builder_fields/form_builder_localized_date_picker.dart b/lib/core/widgets/form_builder_fields/form_builder_localized_date_picker.dart index 369db47..44b5d89 100644 --- a/lib/core/widgets/form_builder_fields/form_builder_localized_date_picker.dart +++ b/lib/core/widgets/form_builder_fields/form_builder_localized_date_picker.dart @@ -1,41 +1,26 @@ import 'dart:collection'; -import 'dart:math'; import 'package:collection/collection.dart'; -import 'package:extended_masked_text/extended_masked_text.dart'; -import 'package:flutter/foundation.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:synchronized/extension.dart'; - -final class NeighbourAwareDateInputSegmentControls - with LinkedListEntry { - final FocusNode node; - final TextEditingController controller; - final int position; - final String format; - final DateTime? initialDate; - - NeighbourAwareDateInputSegmentControls({ - required this.node, - required this.controller, - required this.format, - this.initialDate, - required this.position, - }); -} +import 'package:paperless_mobile/features/landing/view/widgets/mime_types_pie_chart.dart'; +import 'package:paperless_mobile/generated/l10n/app_localizations.dart'; +/// 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 Locale locale; + + /// If set to true, the field will not throw any validation errors when empty. + final bool allowUnset; const FormBuilderLocalizedDatePicker({ super.key, @@ -46,6 +31,7 @@ class FormBuilderLocalizedDatePicker extends StatefulWidget { required this.locale, required this.labelText, this.prefixIcon, + this.allowUnset = false, }); @override @@ -59,8 +45,9 @@ class _FormBuilderLocalizedDatePickerState late final String _format; final _textFieldControls = - LinkedList(); - + LinkedList<_NeighbourAwareDateInputSegmentControls>(); + String? _error; + bool _temporarilyDisableListeners = false; @override void initState() { super.initState(); @@ -78,29 +65,44 @@ class _FormBuilderLocalizedDatePickerState final initialText = widget.initialValue != null ? DateFormat(formatString).format(widget.initialValue!) : null; - final item = NeighbourAwareDateInputSegmentControls( + final controls = _NeighbourAwareDateInputSegmentControls( node: FocusNode(debugLabel: formatString), controller: TextEditingController(text: initialText), format: formatString, position: i, + type: _DateInputSegment.fromPattern(formatString), ); - item.controller.addListener(() { - if (item.controller.text.length == item.format.length) { - // _textFieldControls.elementAt(i).next?.node.requestFocus(); - // _textFieldControls.elementAt(i).next?.controller.selection = - // const TextSelection.collapsed(offset: 0); - // return; + _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(); } }); - item.node.addListener(() { - if (item.node.hasFocus) { - item.controller.selection = const TextSelection.collapsed(offset: 0); + controls.node.addListener(() { + if (_temporarilyDisableListeners || !controls.node.hasFocus) { + return; } + controls.controller.selection = TextSelection( + baseOffset: 0, + extentOffset: controls.controller.text.length, + ); }); - _textFieldControls.add(item); } } + @override + void dispose() { + for (var controls in _textFieldControls) { + controls.node.dispose(); + controls.controller.dispose(); + } + super.dispose(); + } + @override Widget build(BuildContext context) { return RawKeyboardListener( @@ -123,27 +125,50 @@ class _FormBuilderLocalizedDatePickerState } }, child: FormBuilderField( - name: widget.name, - initialValue: widget.initialValue, - validator: (value) { + validator: _validateDate, + onChanged: (value) { + // We have to temporarily disable our listeners on the TextEditingController here + // since otherwise the listeners get notified of the change and + // the fields get focused and highlighted/selected (as defined in the + // listeners above). + _temporarilyDisableListeners = true; + for (var control in _textFieldControls) { + control.controller.text = DateFormat(control.format).format(value!); + } + _temporarilyDisableListeners = false; + + final error = _validateDate(value); + setState(() { + _error = error; + }); + if (value?.isBefore(widget.firstDate) ?? false) { - return "Date must be before " + + setState(() => _error = "Date must be after " + DateFormat.yMd(widget.locale.toString()) - .format(widget.firstDate); + .format(widget.firstDate) + + "."); + return; } if (value?.isAfter(widget.lastDate) ?? false) { - return "Date must be after " + + setState(() => _error = "Date must be before " + DateFormat.yMd(widget.locale.toString()) - .format(widget.lastDate); + .format(widget.lastDate) + + "."); + return; } - return null; }, + autovalidateMode: AutovalidateMode.onUserInteraction, + name: widget.name, + initialValue: widget.initialValue, builder: (field) { - return SizedBox( - height: 56, + return GestureDetector( + onTap: () { + _textFieldControls.first.node.requestFocus(); + }, child: InputDecorator( textAlignVertical: TextAlignVertical.bottom, decoration: InputDecoration( + errorText: _error, labelText: widget.labelText, prefixIcon: widget.prefixIcon, suffixIcon: Row( @@ -168,11 +193,11 @@ class _FormBuilderLocalizedDatePickerState ), IconButton( onPressed: () { - field.didChange(null); for (var c in _textFieldControls) { c.controller.clear(); } _textFieldControls.first.node.requestFocus(); + field.didChange(null); }, icon: const Icon(Icons.clear), ), @@ -182,16 +207,9 @@ class _FormBuilderLocalizedDatePickerState child: Row( children: [ for (var s in _textFieldControls) ...[ - SizedBox( - width: switch (s.format) { - == "dd" => 32, - == "MM" => 32, - == "yyyy" => 48, - _ => 0, - }, + IntrinsicWidth( child: _buildDateSegmentInput(s, context, field), ), - if (s.position < 2) Text(_separator).paddedOnly(right: 4), ], ], ), @@ -202,6 +220,26 @@ class _FormBuilderLocalizedDatePickerState ); } + String? _validateDate(DateTime? date) { + if (widget.allowUnset && date == null) { + return null; + } + if (date == null) { + return S.of(context)!.thisFieldIsRequired; + } + if (date.isBefore(widget.firstDate)) { + final formattedDateHint = + DateFormat.yMd(widget.locale.toString()).format(widget.firstDate); + return "Date must be after $formattedDateHint."; + } + if (date.isAfter(widget.lastDate)) { + final formattedDateHint = + DateFormat.yMd(widget.locale.toString()).format(widget.lastDate); + return "Date must be before $formattedDateHint."; + } + return null; + } + void _updateInputsWithDate(DateTime date) { final components = _format.split(_separator); for (int i = 0; i < components.length; i++) { @@ -212,38 +250,69 @@ class _FormBuilderLocalizedDatePickerState } Widget _buildDateSegmentInput( - NeighbourAwareDateInputSegmentControls controls, + _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'); + } _textFieldControls .elementAt(controls.position) .next ?.node .requestFocus(); }, - // onTap: () { - // controls.controller.clear(); - // }, - canRequestFocus: true, + style: const TextStyle(fontFamily: 'RobotoMono'), keyboardType: TextInputType.datetime, - textInputAction: TextInputAction.done, + textInputAction: + controls.position < 2 ? TextInputAction.next : TextInputAction.done, controller: controls.controller, focusNode: _textFieldControls.elementAt(controls.position).node, maxLength: controls.format.length, - maxLengthEnforcement: MaxLengthEnforcement.truncateAfterCompositionEnds, + maxLengthEnforcement: MaxLengthEnforcement.enforced, enableInteractiveSelection: false, + onChanged: (value) { + if (value.length == controls.format.length && field.value != null) { + final number = int.tryParse(value); + if (number == null) { + return; + } + final newValue = switch (controls.type) { + _DateInputSegment.day => field.value!.copyWith(day: number), + _DateInputSegment.month => field.value!.copyWith(month: number), + _DateInputSegment.year => field.value!.copyWith(year: number), + }; + field.didChange(newValue); + } + }, inputFormatters: [ FilteringTextInputFormatter.digitsOnly, - ReplacingTextFormatter(), + RangeLimitedInputFormatter( + 1, + switch (controls.type) { + _DateInputSegment.day => 31, + _DateInputSegment.month => 12, + _DateInputSegment.year => 9999, + }, + ), ], decoration: InputDecoration( isDense: true, - contentPadding: EdgeInsets.zero, + 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, @@ -255,31 +324,64 @@ class _FormBuilderLocalizedDatePickerState } } -class ReplacingTextFormatter extends TextInputFormatter { +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, ) { - final oldOffset = oldValue.selection.baseOffset; - final newOffset = newValue.selection.baseOffset; - final replacement = newValue.text.substring(oldOffset, newOffset); - print( - "DBG: Received ${oldValue.text} -> ${newValue.text}. New char = $replacement"); - if (oldOffset < newOffset) { - final oldText = oldValue.text; - final newText = oldText.replaceRange( - oldOffset, - newOffset, - newValue.text.substring(oldOffset, newOffset), - ); - print("DBG: Replacing $oldText -> $newText"); - return newValue.copyWith( - text: newText, - selection: TextSelection.collapsed(offset: newOffset), + 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/features/app_drawer/view/app_drawer.dart b/lib/features/app_drawer/view/app_drawer.dart index e679bdd..ca4fdf2 100644 --- a/lib/features/app_drawer/view/app_drawer.dart +++ b/lib/features/app_drawer/view/app_drawer.dart @@ -129,7 +129,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/document_edit/view/document_edit_page.dart b/lib/features/document_edit/view/document_edit_page.dart index b486c87..c1205c4 100644 --- a/lib/features/document_edit/view/document_edit_page.dart +++ b/lib/features/document_edit/view/document_edit_page.dart @@ -413,28 +413,16 @@ class _DocumentEditPageState extends State { Widget _buildCreatedAtFormField( DateTime? initialCreatedAtDate, FieldSuggestions? filteredSuggestions) { - // return FormBuilderLocalizedDatePicker( - // name: fkCreatedDate, - // initialValue: initialCreatedAtDate, - // labelText: S.of(context)!.createdAt, - // firstDate: DateTime(1970, 1, 1), - // lastDate: DateTime.now(), - // locale: Localizations.localeOf(context), - // prefixIcon: Icon(Icons.calendar_today), - // ); 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( 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/pubspec.yaml b/pubspec.yaml index 129123b..04e7bc0 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -155,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" From 652abb6945ad9941765f8bb4e090eb4f59e1f7e3 Mon Sep 17 00:00:00 2001 From: Anton Stubenbord Date: Fri, 20 Oct 2023 17:28:54 +0200 Subject: [PATCH 5/6] feat: Add improved date input, fix bugs, restructurings --- ...tensions.dart => document_extensions.dart} | 7 + lib/core/repository/label_repository.dart | 15 +- .../form_builder_localized_date_picker.dart | 169 +++--- .../cubit/document_details_cubit.dart | 150 +++--- .../cubit/document_details_state.dart | 49 +- .../view/pages/document_details_page.dart | 484 ++++++++++-------- .../widgets/archive_serial_number_field.dart | 11 +- .../view/widgets/document_content_widget.dart | 42 +- .../widgets/document_meta_data_widget.dart | 116 ++--- .../widgets/document_overview_widget.dart | 2 + .../cubit/document_edit_cubit.dart | 17 - .../cubit/document_edit_state.dart | 4 - .../view/document_edit_page.dart | 80 +-- .../view/document_search_page.dart | 9 +- .../cubit/document_upload_cubit.dart | 27 +- .../cubit/document_upload_state.dart | 26 +- .../document_upload_preparation_page.dart | 116 ++--- .../documents/cubit/documents_cubit.dart | 2 +- .../documents/view/pages/documents_page.dart | 7 +- .../view/widgets/document_preview.dart | 17 +- .../widgets/items/document_detailed_item.dart | 112 ++-- .../widgets/items/document_grid_item.dart | 2 +- .../widgets/items/document_list_item.dart | 55 +- .../inbox/view/widgets/inbox_item.dart | 8 +- .../view/linked_documents_page.dart | 5 +- .../cubit/saved_view_preview_cubit.dart | 2 +- .../view/saved_view_preview.dart | 9 +- .../view/similar_documents_view.dart | 5 +- .../typed/branches/documents_route.dart | 29 +- .../typed/shells/authenticated_route.dart | 2 +- .../paperless_documents_api.dart | 4 +- .../paperless_documents_api_impl.dart | 32 +- 32 files changed, 840 insertions(+), 775 deletions(-) rename lib/core/extensions/{document_iterable_extensions.dart => document_extensions.dart} (68%) diff --git a/lib/core/extensions/document_iterable_extensions.dart b/lib/core/extensions/document_extensions.dart similarity index 68% rename from lib/core/extensions/document_iterable_extensions.dart rename to lib/core/extensions/document_extensions.dart index 0bb9718..a767023 100644 --- a/lib/core/extensions/document_iterable_extensions.dart +++ b/lib/core/extensions/document_extensions.dart @@ -1,4 +1,6 @@ import 'package:collection/collection.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:paperless_api/paperless_api.dart'; extension DocumentModelIterableExtension on Iterable { @@ -16,3 +18,8 @@ extension DocumentModelIterableExtension on Iterable { return whereNot((element) => element.id == document.id); } } + +extension SessionAwareDownloadIdExtension on DocumentModel { + String buildThumbnailUrl(BuildContext context) => + context.read().getThumbnailUrl(id); +} diff --git a/lib/core/repository/label_repository.dart b/lib/core/repository/label_repository.dart index 4e068df..7c2d3ef 100644 --- a/lib/core/repository/label_repository.dart +++ b/lib/core/repository/label_repository.dart @@ -1,6 +1,5 @@ import 'dart:async'; -import 'package:flutter/widgets.dart'; import 'package:paperless_api/paperless_api.dart'; import 'package:paperless_mobile/core/repository/label_repository_state.dart'; import 'package:paperless_mobile/core/repository/persistent_repository.dart'; @@ -11,14 +10,12 @@ class LabelRepository extends PersistentRepository { LabelRepository(this._api) : super(const LabelRepositoryState()); Future initialize() async { - - await Future.wait([ - findAllCorrespondents(), - findAllDocumentTypes(), - findAllStoragePaths(), - findAllTags(), - ]); - + await Future.wait([ + findAllCorrespondents(), + findAllDocumentTypes(), + findAllStoragePaths(), + findAllTags(), + ]); } Future createTag(Tag object) async { diff --git a/lib/core/widgets/form_builder_fields/form_builder_localized_date_picker.dart b/lib/core/widgets/form_builder_fields/form_builder_localized_date_picker.dart index 44b5d89..2d25a92 100644 --- a/lib/core/widgets/form_builder_fields/form_builder_localized_date_picker.dart +++ b/lib/core/widgets/form_builder_fields/form_builder_localized_date_picker.dart @@ -1,3 +1,5 @@ +// ignore_for_file: invalid_use_of_protected_member + import 'dart:collection'; import 'package:collection/collection.dart'; @@ -9,6 +11,39 @@ import 'package:paperless_mobile/core/extensions/flutter_extensions.dart'; import 'package:paperless_mobile/features/landing/view/widgets/mime_types_pie_chart.dart'; import 'package:paperless_mobile/generated/l10n/app_localizations.dart'; +class FormDateTime { + final int? day; + final int? month; + final int? year; + + FormDateTime({this.day, this.month, this.year}); + + FormDateTime.fromDateTime(DateTime date) + : day = date.day, + month = date.month, + year = date.year; + + FormDateTime copyWith({int? day, int? month, int? year}) { + return FormDateTime( + day: day ?? this.day, + month: month ?? this.month, + year: year ?? this.year, + ); + } + + bool get isComplete => day != null && month != null && year != null; + + DateTime? toDateTime() { + if (day == null && month == null && year == null) { + return null; + } + if (!isComplete) { + throw ArgumentError.notNull("day, month and year must be set together"); + } + return DateTime(year!, month!, day!); + } +} + /// A localized, segmented date input field. class FormBuilderLocalizedDatePicker extends StatefulWidget { final String name; @@ -124,42 +159,35 @@ class _FormBuilderLocalizedDatePickerState } } }, - child: FormBuilderField( + child: FormBuilderField( + name: widget.name, validator: _validateDate, onChanged: (value) { - // We have to temporarily disable our listeners on the TextEditingController here - // since otherwise the listeners get notified of the change and - // the fields get focused and highlighted/selected (as defined in the - // listeners above). - _temporarilyDisableListeners = true; - for (var control in _textFieldControls) { - control.controller.text = DateFormat(control.format).format(value!); + assert(!widget.allowUnset && value != null); + if (value == null) { + return; } - _temporarilyDisableListeners = false; + // When the change is requested from external sources, such as calling + // field.didChange(value), then we want to update the text fields individually + // without causing the either field to gain focus (as defined above). + final isChangeRequestedFromOutside = + _textFieldControls.none((element) => element.node.hasFocus); + if (isChangeRequestedFromOutside) { + _updateInputsWithDate(value, disableListeners: true); + } + // Imitate the functionality of the validator function in "normal" form fields. + // The error is shown on the outer decorator as if this was a regular text input. + // Errors are cleared after the next user interaction. final error = _validateDate(value); setState(() { _error = error; }); - - if (value?.isBefore(widget.firstDate) ?? false) { - setState(() => _error = "Date must be after " + - DateFormat.yMd(widget.locale.toString()) - .format(widget.firstDate) + - "."); - return; - } - if (value?.isAfter(widget.lastDate) ?? false) { - setState(() => _error = "Date must be before " + - DateFormat.yMd(widget.locale.toString()) - .format(widget.lastDate) + - "."); - return; - } }, autovalidateMode: AutovalidateMode.onUserInteraction, - name: widget.name, - initialValue: widget.initialValue, + initialValue: widget.initialValue != null + ? FormDateTime.fromDateTime(widget.initialValue!) + : null, builder: (field) { return GestureDetector( onTap: () { @@ -170,7 +198,6 @@ class _FormBuilderLocalizedDatePickerState decoration: InputDecoration( errorText: _error, labelText: widget.labelText, - prefixIcon: widget.prefixIcon, suffixIcon: Row( mainAxisSize: MainAxisSize.min, children: [ @@ -179,28 +206,33 @@ class _FormBuilderLocalizedDatePickerState onPressed: () async { final selectedDate = await showDatePicker( context: context, - initialDate: widget.initialValue ?? DateTime.now(), + initialDate: + field.value?.toDateTime() ?? DateTime.now(), firstDate: widget.firstDate, lastDate: widget.lastDate, initialEntryMode: DatePickerEntryMode.calendarOnly, ); if (selectedDate != null) { - _updateInputsWithDate(selectedDate); - field.didChange(selectedDate); - FocusScope.of(context).unfocus(); + final formDate = + FormDateTime.fromDateTime(selectedDate); + _temporarilyDisableListeners = true; + _updateInputsWithDate(formDate); + field.didChange(formDate); + _temporarilyDisableListeners = false; } }, ), - IconButton( - onPressed: () { - for (var c in _textFieldControls) { - c.controller.clear(); - } - _textFieldControls.first.node.requestFocus(); - field.didChange(null); - }, - icon: const Icon(Icons.clear), - ), + if (widget.allowUnset) + IconButton( + onPressed: () { + for (var c in _textFieldControls) { + c.controller.clear(); + } + _textFieldControls.first.node.requestFocus(); + field.didChange(null); + }, + icon: const Icon(Icons.clear), + ), ], ).paddedOnly(right: 4), ), @@ -220,19 +252,26 @@ class _FormBuilderLocalizedDatePickerState ); } - String? _validateDate(DateTime? date) { + String? _validateDate(FormDateTime? date) { if (widget.allowUnset && date == null) { return null; } if (date == null) { return S.of(context)!.thisFieldIsRequired; } - if (date.isBefore(widget.firstDate)) { + final d = date.toDateTime(); + if (d == null) { + return S.of(context)!.thisFieldIsRequired; + } + if (d.day != date.day && d.month != date.month && d.year != date.year) { + return "Invalid date."; + } + if (d.isBefore(widget.firstDate)) { final formattedDateHint = DateFormat.yMd(widget.locale.toString()).format(widget.firstDate); return "Date must be after $formattedDateHint."; } - if (date.isAfter(widget.lastDate)) { + if (d.isAfter(widget.lastDate)) { final formattedDateHint = DateFormat.yMd(widget.locale.toString()).format(widget.lastDate); return "Date must be before $formattedDateHint."; @@ -240,30 +279,31 @@ class _FormBuilderLocalizedDatePickerState return null; } - void _updateInputsWithDate(DateTime date) { - final components = _format.split(_separator); - for (int i = 0; i < components.length; i++) { - final formatString = components[i]; - final value = DateFormat(formatString).format(date); - _textFieldControls.elementAt(i).controller.text = value; + void _updateInputsWithDate( + FormDateTime date, { + bool disableListeners = false, + }) { + if (disableListeners) { + _temporarilyDisableListeners = true; } + for (var controls in _textFieldControls) { + final value = DateFormat(controls.format).format(date.toDateTime()!); + controls.controller.text = value; + } + _temporarilyDisableListeners = false; } Widget _buildDateSegmentInput( _NeighbourAwareDateInputSegmentControls controls, BuildContext context, - FormFieldState field, + FormFieldState field, ) { return TextFormField( onFieldSubmitted: (value) { if (value.length < controls.format.length) { controls.controller.text = value.padLeft(controls.format.length, '0'); } - _textFieldControls - .elementAt(controls.position) - .next - ?.node - .requestFocus(); + controls.next?.node.requestFocus(); }, style: const TextStyle(fontFamily: 'RobotoMono'), keyboardType: TextInputType.datetime, @@ -275,17 +315,18 @@ class _FormBuilderLocalizedDatePickerState maxLengthEnforcement: MaxLengthEnforcement.enforced, enableInteractiveSelection: false, onChanged: (value) { - if (value.length == controls.format.length && field.value != null) { + if (value.length == controls.format.length) { final number = int.tryParse(value); if (number == null) { return; } + final fieldValue = field.value ?? FormDateTime(); final newValue = switch (controls.type) { - _DateInputSegment.day => field.value!.copyWith(day: number), - _DateInputSegment.month => field.value!.copyWith(month: number), - _DateInputSegment.year => field.value!.copyWith(year: number), + _DateInputSegment.day => fieldValue.copyWith(day: number), + _DateInputSegment.month => fieldValue.copyWith(month: number), + _DateInputSegment.year => fieldValue.copyWith(year: number), }; - field.didChange(newValue); + field.setValue(newValue); } }, inputFormatters: [ @@ -299,6 +340,12 @@ class _FormBuilderLocalizedDatePickerState }, ), ], + onEditingComplete: () { + if (field.value != null) { + _updateInputsWithDate(field.value!, disableListeners: true); + } + FocusScope.of(context).unfocus(); + }, decoration: InputDecoration( isDense: true, suffixIcon: controls.position < 2 diff --git a/lib/features/document_details/cubit/document_details_cubit.dart b/lib/features/document_details/cubit/document_details_cubit.dart index eae2199..4b38265 100644 --- a/lib/features/document_details/cubit/document_details_cubit.dart +++ b/lib/features/document_details/cubit/document_details_cubit.dart @@ -2,23 +2,23 @@ import 'dart:async'; import 'dart:io'; import 'package:bloc/bloc.dart'; +import 'package:cross_file/cross_file.dart'; import 'package:flutter/material.dart'; -import 'package:freezed_annotation/freezed_annotation.dart'; import 'package:open_filex/open_filex.dart'; import 'package:paperless_api/paperless_api.dart'; -import 'package:paperless_mobile/features/logging/data/logger.dart'; import 'package:paperless_mobile/core/notifier/document_changed_notifier.dart'; import 'package:paperless_mobile/core/repository/label_repository.dart'; import 'package:paperless_mobile/core/service/file_service.dart'; +import 'package:paperless_mobile/features/logging/data/logger.dart'; import 'package:paperless_mobile/features/notifications/services/local_notification_service.dart'; +import 'package:path/path.dart' as p; import 'package:printing/printing.dart'; import 'package:share_plus/share_plus.dart'; -import 'package:cross_file/cross_file.dart'; -import 'package:path/path.dart' as p; -part 'document_details_cubit.freezed.dart'; + part 'document_details_state.dart'; class DocumentDetailsCubit extends Cubit { + final int id; final PaperlessDocumentsApi _api; final DocumentChangedNotifier _notifier; final LocalNotificationService _notificationService; @@ -29,24 +29,46 @@ class DocumentDetailsCubit extends Cubit { this._labelRepository, this._notifier, this._notificationService, { - required DocumentModel initialDocument, - }) : super(DocumentDetailsState(document: initialDocument)) { + required this.id, + }) : super(const DocumentDetailsInitial()) { _notifier.addListener(this, onUpdated: (document) { - if (document.id == state.document.id) { - replace(document); + if (state is DocumentDetailsLoaded) { + final currentState = state as DocumentDetailsLoaded; + if (document.id == currentState.document.id) { + replace(document); + } } }); - _labelRepository.addListener( - this, - onChanged: (labels) => emit( - state.copyWith( - correspondents: labels.correspondents, - documentTypes: labels.documentTypes, - tags: labels.tags, - storagePaths: labels.storagePaths, - ), - ), - ); + } + + Future initialize() async { + debugPrint("Initialize called"); + emit(const DocumentDetailsLoading()); + try { + final (document, metaData) = await Future.wait([ + _api.find(id), + _api.getMetaData(id), + ]).then((value) => ( + value[0] as DocumentModel, + value[1] as DocumentMetaData, + )); + // final document = await _api.find(id); + // final metaData = await _api.getMetaData(id); + debugPrint("Document data loaded for $id"); + emit(DocumentDetailsLoaded( + document: document, + metaData: metaData, + )); + } catch (error, stackTrace) { + logger.fe( + "An error occurred while loading data for document $id.", + className: runtimeType.toString(), + methodName: 'initialize', + error: error, + stackTrace: stackTrace, + ); + emit(const DocumentDetailsError()); + } } Future delete(DocumentModel document) async { @@ -54,20 +76,6 @@ class DocumentDetailsCubit extends Cubit { _notifier.notifyDeleted(document); } - Future loadMetaData() async { - final metaData = await _api.getMetaData(state.document); - if (!isClosed) { - emit(state.copyWith(metaData: metaData)); - } - } - - Future loadFullContent() async { - await Future.delayed(const Duration(seconds: 5)); - final doc = await _api.find(state.document.id); - _notifier.notifyUpdated(doc); - emit(state.copyWith(isFullContentLoaded: true)); - } - Future assignAsn( DocumentModel document, { int? asn, @@ -87,11 +95,15 @@ class DocumentDetailsCubit extends Cubit { } Future openDocumentInSystemViewer() async { - final cacheDir = FileService.instance.temporaryDirectory; - if (state.metaData == null) { - await loadMetaData(); + final s = state; + if (s is! DocumentDetailsLoaded) { + throw Exception( + "Document cannot be opened in system viewer " + "if document information has not yet been loaded.", + ); } - final filePath = state.metaData!.mediaFilename.replaceAll("/", " "); + final cacheDir = FileService.instance.temporaryDirectory; + final filePath = s.metaData.mediaFilename.replaceAll("/", " "); final fileName = "${p.basenameWithoutExtension(filePath)}.pdf"; final file = File("${cacheDir.path}/$fileName"); @@ -99,7 +111,7 @@ class DocumentDetailsCubit extends Cubit { if (!file.existsSync()) { file.createSync(); await _api.downloadToFile( - state.document, + s.document, file.path, ); } @@ -110,7 +122,14 @@ class DocumentDetailsCubit extends Cubit { } void replace(DocumentModel document) { - emit(state.copyWith(document: document)); + final s = state; + if (s is! DocumentDetailsLoaded) { + return; + } + emit(DocumentDetailsLoaded( + document: document, + metaData: s.metaData, + )); } Future downloadDocument({ @@ -118,10 +137,12 @@ class DocumentDetailsCubit extends Cubit { required String locale, required String userId, }) async { - if (state.metaData == null) { - await loadMetaData(); + final s = state; + if (s is! DocumentDetailsLoaded) { + return; } String targetPath = _buildDownloadFilePath( + s.metaData, downloadOriginal, FileService.instance.downloadsDirectory, ); @@ -130,7 +151,7 @@ class DocumentDetailsCubit extends Cubit { await File(targetPath).create(); } else { await _notificationService.notifyDocumentDownload( - document: state.document, + document: s.document, filename: p.basename(targetPath), filePath: targetPath, finished: true, @@ -149,12 +170,12 @@ class DocumentDetailsCubit extends Cubit { // ); await _api.downloadToFile( - state.document, + s.document, targetPath, original: downloadOriginal, onProgressChanged: (progress) { _notificationService.notifyDocumentDownload( - document: state.document, + document: s.document, filename: p.basename(targetPath), filePath: targetPath, finished: true, @@ -165,26 +186,28 @@ class DocumentDetailsCubit extends Cubit { }, ); await _notificationService.notifyDocumentDownload( - document: state.document, + document: s.document, filename: p.basename(targetPath), filePath: targetPath, finished: true, locale: locale, userId: userId, ); - logger.fi("Document '${state.document.title}' saved to $targetPath."); + logger.fi("Document '${s.document.title}' saved to $targetPath."); } Future shareDocument({bool shareOriginal = false}) async { - if (state.metaData == null) { - await loadMetaData(); + final s = state; + if (s is! DocumentDetailsLoaded) { + return; } String filePath = _buildDownloadFilePath( + s.metaData, shareOriginal, FileService.instance.temporaryDirectory, ); await _api.downloadToFile( - state.document, + s.document, filePath, original: shareOriginal, ); @@ -192,23 +215,27 @@ class DocumentDetailsCubit extends Cubit { [ XFile( filePath, - name: state.document.originalFileName, + name: s.document.originalFileName, mimeType: "application/pdf", - lastModified: state.document.modified, + lastModified: s.document.modified, ), ], - subject: state.document.title, + subject: s.document.title, ); } Future printDocument() async { - if (state.metaData == null) { - await loadMetaData(); + final s = state; + if (s is! DocumentDetailsLoaded) { + return; } - final filePath = - _buildDownloadFilePath(false, FileService.instance.temporaryDirectory); + final filePath = _buildDownloadFilePath( + s.metaData, + false, + FileService.instance.temporaryDirectory, + ); await _api.downloadToFile( - state.document, + s.document, filePath, original: false, ); @@ -217,13 +244,14 @@ class DocumentDetailsCubit extends Cubit { throw Exception("An error occurred while downloading the document."); } Printing.layoutPdf( - name: state.document.title, + name: s.document.title, onLayout: (format) => file.readAsBytesSync(), ); } - String _buildDownloadFilePath(bool original, Directory dir) { - final normalizedPath = state.metaData!.mediaFilename.replaceAll("/", " "); + String _buildDownloadFilePath( + DocumentMetaData meta, bool original, Directory dir) { + final normalizedPath = meta.mediaFilename.replaceAll("/", " "); final extension = original ? p.extension(normalizedPath) : '.pdf'; return "${dir.path}/${p.basenameWithoutExtension(normalizedPath)}$extension"; } diff --git a/lib/features/document_details/cubit/document_details_state.dart b/lib/features/document_details/cubit/document_details_state.dart index d24a593..0d7bbcd 100644 --- a/lib/features/document_details/cubit/document_details_state.dart +++ b/lib/features/document_details/cubit/document_details_state.dart @@ -1,14 +1,41 @@ part of 'document_details_cubit.dart'; -@freezed -class DocumentDetailsState with _$DocumentDetailsState { - const factory DocumentDetailsState({ - required DocumentModel document, - DocumentMetaData? metaData, - @Default(false) bool isFullContentLoaded, - @Default({}) Map correspondents, - @Default({}) Map documentTypes, - @Default({}) Map tags, - @Default({}) Map storagePaths, - }) = _DocumentDetailsState; +sealed class DocumentDetailsState { + const DocumentDetailsState(); } + +class DocumentDetailsInitial extends DocumentDetailsState { + const DocumentDetailsInitial(); +} + +class DocumentDetailsLoading extends DocumentDetailsState { + const DocumentDetailsLoading(); +} + +class DocumentDetailsLoaded extends DocumentDetailsState { + final DocumentModel document; + final DocumentMetaData metaData; + + const DocumentDetailsLoaded({ + required this.document, + required this.metaData, + }); +} + +class DocumentDetailsError extends DocumentDetailsState { + const DocumentDetailsError(); +} + + +// @freezed +// class DocumentDetailsState with _$DocumentDetailsState { +// const factory DocumentDetailsState({ +// required DocumentModel document, +// DocumentMetaData? metaData, +// @Default(false) bool isFullContentLoaded, +// @Default({}) Map correspondents, +// @Default({}) Map documentTypes, +// @Default({}) Map tags, +// @Default({}) Map storagePaths, +// }) = _DocumentDetailsState; +// } diff --git a/lib/features/document_details/view/pages/document_details_page.dart b/lib/features/document_details/view/pages/document_details_page.dart index 9225d8c..fff3bbe 100644 --- a/lib/features/document_details/view/pages/document_details_page.dart +++ b/lib/features/document_details/view/pages/document_details_page.dart @@ -2,20 +2,18 @@ import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:go_router/go_router.dart'; import 'package:intl/date_symbol_data_local.dart'; -import 'package:intl/intl.dart'; import 'package:open_filex/open_filex.dart'; import 'package:paperless_api/paperless_api.dart'; import 'package:paperless_mobile/core/bloc/connectivity_cubit.dart'; import 'package:paperless_mobile/core/database/tables/local_user_account.dart'; +import 'package:paperless_mobile/core/extensions/flutter_extensions.dart'; import 'package:paperless_mobile/core/translation/error_code_localization_mapper.dart'; import 'package:paperless_mobile/core/widgets/material/colored_tab_bar.dart'; -import 'package:paperless_mobile/core/extensions/flutter_extensions.dart'; import 'package:paperless_mobile/features/document_details/cubit/document_details_cubit.dart'; import 'package:paperless_mobile/features/document_details/view/widgets/document_content_widget.dart'; import 'package:paperless_mobile/features/document_details/view/widgets/document_download_button.dart'; import 'package:paperless_mobile/features/document_details/view/widgets/document_meta_data_widget.dart'; import 'package:paperless_mobile/features/document_details/view/widgets/document_overview_widget.dart'; -import 'package:paperless_mobile/features/document_details/view/widgets/document_permissions_widget.dart'; import 'package:paperless_mobile/features/document_details/view/widgets/document_share_button.dart'; import 'package:paperless_mobile/features/documents/view/widgets/delete_document_confirmation_dialog.dart'; import 'package:paperless_mobile/features/documents/view/widgets/document_preview.dart'; @@ -29,13 +27,21 @@ import 'package:paperless_mobile/routes/typed/shells/authenticated_route.dart'; import 'package:paperless_mobile/theme.dart'; class DocumentDetailsPage extends StatefulWidget { + final int id; + final String? title; final bool isLabelClickable; final String? titleAndContentQueryString; + final String? thumbnailUrl; + final String? heroTag; const DocumentDetailsPage({ Key? key, this.isLabelClickable = true, this.titleAndContentQueryString, + this.thumbnailUrl, + required this.id, + this.heroTag, + this.title, }) : super(key: key); @override @@ -57,152 +63,157 @@ class _DocumentDetailsPageState extends State { final hasMultiUserSupport = context.watch().hasMultiUserSupport; final tabLength = 4 + (hasMultiUserSupport && false ? 1 : 0); - final title = context.watch().state.document.title; return AnnotatedRegion( value: buildOverlayStyle( Theme.of(context), systemNavigationBarColor: Theme.of(context).bottomAppBarTheme.color, ), - child: WillPopScope( - onWillPop: () async { - Navigator.of(context) - .pop(context.read().state.document); - return false; - }, - child: DefaultTabController( - length: tabLength, - child: BlocListener( - listenWhen: (previous, current) => - !previous.isConnected && current.isConnected, - listener: (context, state) { - context.read().loadMetaData(); - }, + child: BlocBuilder( + builder: (context, state) { + return DefaultTabController( + length: tabLength, child: Scaffold( extendBodyBehindAppBar: false, floatingActionButtonLocation: FloatingActionButtonLocation.endDocked, - floatingActionButton: _buildEditButton(), + floatingActionButton: switch (state) { + DocumentDetailsLoaded(document: var document) => + _buildEditButton(document), + _ => null + }, bottomNavigationBar: _buildBottomAppBar(), body: NestedScrollView( headerSliverBuilder: (context, innerBoxIsScrolled) => [ SliverOverlapAbsorber( handle: NestedScrollView.sliverOverlapAbsorberHandleFor( context), - sliver: SliverAppBar( - title: Text(title), - leading: const BackButton(), - pinned: true, - forceElevated: innerBoxIsScrolled, - collapsedHeight: kToolbarHeight, - expandedHeight: 250.0, - flexibleSpace: FlexibleSpaceBar( - background: BlocBuilder( - builder: (context, state) { - return Hero( - tag: "thumb_${state.document.id}", - child: GestureDetector( - onTap: () { - DocumentPreviewRoute($extra: state.document) - .push(context); - }, - child: Stack( - alignment: Alignment.topCenter, - children: [ - Positioned.fill( - child: DocumentPreview( - enableHero: false, - document: state.document, - fit: BoxFit.cover, - alignment: Alignment.topCenter, - ), - ), - Positioned.fill( - child: DecoratedBox( - decoration: BoxDecoration( - gradient: LinearGradient( - stops: [0.2, 0.4], - colors: [ - Theme.of(context) - .colorScheme - .background - .withOpacity(0.6), - Theme.of(context) - .colorScheme - .background - .withOpacity(0.3), - ], - begin: Alignment.topCenter, - end: Alignment.bottomCenter, + sliver: + BlocBuilder( + builder: (context, state) { + final title = switch (state) { + DocumentDetailsLoaded(document: var document) => + document.title, + _ => widget.title ?? '', + }; + return SliverAppBar( + title: Text(title), + leading: const BackButton(), + pinned: true, + forceElevated: innerBoxIsScrolled, + collapsedHeight: kToolbarHeight, + expandedHeight: 250.0, + flexibleSpace: FlexibleSpaceBar( + background: Builder( + builder: (context) { + return Hero( + tag: widget.heroTag ?? "thumb_${widget.id}", + child: GestureDetector( + onTap: () { + DocumentPreviewRoute( + id: widget.id, + title: title, + ).push(context); + }, + child: Stack( + alignment: Alignment.topCenter, + children: [ + Positioned.fill( + child: DocumentPreview( + documentId: widget.id, + title: title, + enableHero: false, + fit: BoxFit.cover, + alignment: Alignment.topCenter, ), ), - ), + Positioned.fill( + child: DecoratedBox( + decoration: BoxDecoration( + gradient: LinearGradient( + stops: [0.2, 0.4], + colors: [ + Theme.of(context) + .colorScheme + .background + .withOpacity(0.6), + Theme.of(context) + .colorScheme + .background + .withOpacity(0.3), + ], + begin: Alignment.topCenter, + end: Alignment.bottomCenter, + ), + ), + ), + ), + ], ), - ], - ), - ), - ); - }, - ), - ), - bottom: ColoredTabBar( - tabBar: TabBar( - isScrollable: true, - tabs: [ - Tab( - child: Text( - S.of(context)!.overview, - style: TextStyle( - color: Theme.of(context) - .colorScheme - .onPrimaryContainer, - ), - ), + ), + ); + }, ), - Tab( - child: Text( - S.of(context)!.content, - style: TextStyle( - color: Theme.of(context) - .colorScheme - .onPrimaryContainer, - ), - ), - ), - Tab( - child: Text( - S.of(context)!.metaData, - style: TextStyle( - color: Theme.of(context) - .colorScheme - .onPrimaryContainer, - ), - ), - ), - Tab( - child: Text( - S.of(context)!.similarDocuments, - style: TextStyle( - color: Theme.of(context) - .colorScheme - .onPrimaryContainer, - ), - ), - ), - if (hasMultiUserSupport && false) - Tab( - child: Text( - "Permissions", - style: TextStyle( - color: Theme.of(context) - .colorScheme - .onPrimaryContainer, + ), + bottom: ColoredTabBar( + tabBar: TabBar( + isScrollable: true, + tabs: [ + Tab( + child: Text( + S.of(context)!.overview, + style: TextStyle( + color: Theme.of(context) + .colorScheme + .onPrimaryContainer, + ), ), ), - ), - ], - ), - ), + Tab( + child: Text( + S.of(context)!.content, + style: TextStyle( + color: Theme.of(context) + .colorScheme + .onPrimaryContainer, + ), + ), + ), + Tab( + child: Text( + S.of(context)!.metaData, + style: TextStyle( + color: Theme.of(context) + .colorScheme + .onPrimaryContainer, + ), + ), + ), + Tab( + child: Text( + S.of(context)!.similarDocuments, + style: TextStyle( + color: Theme.of(context) + .colorScheme + .onPrimaryContainer, + ), + ), + ), + // if (hasMultiUserSupport && false) + // Tab( + // child: Text( + // "Permissions", + // style: TextStyle( + // color: Theme.of(context) + // .colorScheme + // .onPrimaryContainer, + // ), + // ), + // ), + ], + ), + ), + ); + }, ), ), ], @@ -214,7 +225,7 @@ class _DocumentDetailsPageState extends State { context.read(), context.read(), context.read(), - documentId: state.document.id, + documentId: widget.id, ), child: Padding( padding: const EdgeInsets.symmetric( @@ -229,12 +240,19 @@ class _DocumentDetailsPageState extends State { handle: NestedScrollView .sliverOverlapAbsorberHandleFor(context), ), - DocumentOverviewWidget( - document: state.document, - itemSpacing: _itemSpacing, - queryString: - widget.titleAndContentQueryString, - ), + switch (state) { + DocumentDetailsLoaded( + document: var document + ) => + DocumentOverviewWidget( + document: document, + itemSpacing: _itemSpacing, + queryString: + widget.titleAndContentQueryString, + ), + DocumentDetailsError() => _buildErrorState(), + _ => _buildLoadingState(), + }, ], ), CustomScrollView( @@ -243,13 +261,18 @@ class _DocumentDetailsPageState extends State { handle: NestedScrollView .sliverOverlapAbsorberHandleFor(context), ), - DocumentContentWidget( - isFullContentLoaded: - state.isFullContentLoaded, - document: state.document, - queryString: - widget.titleAndContentQueryString, - ), + switch (state) { + DocumentDetailsLoaded( + document: var document + ) => + DocumentContentWidget( + document: document, + queryString: + widget.titleAndContentQueryString, + ), + DocumentDetailsError() => _buildErrorState(), + _ => _buildLoadingState(), + } ], ), CustomScrollView( @@ -258,10 +281,19 @@ class _DocumentDetailsPageState extends State { handle: NestedScrollView .sliverOverlapAbsorberHandleFor(context), ), - DocumentMetaDataWidget( - document: state.document, - itemSpacing: _itemSpacing, - ), + switch (state) { + DocumentDetailsLoaded( + document: var document, + metaData: var metaData, + ) => + DocumentMetaDataWidget( + document: document, + itemSpacing: _itemSpacing, + metaData: metaData, + ), + DocumentDetailsError() => _buildErrorState(), + _ => _buildLoadingState(), + }, ], ), CustomScrollView( @@ -277,20 +309,20 @@ class _DocumentDetailsPageState extends State { ), ], ), - if (hasMultiUserSupport && false) - CustomScrollView( - controller: _pagingScrollController, - slivers: [ - SliverOverlapInjector( - handle: NestedScrollView - .sliverOverlapAbsorberHandleFor( - context), - ), - DocumentPermissionsWidget( - document: state.document, - ), - ], - ), + // if (hasMultiUserSupport && false) + // CustomScrollView( + // controller: _pagingScrollController, + // slivers: [ + // SliverOverlapInjector( + // handle: NestedScrollView + // .sliverOverlapAbsorberHandleFor( + // context), + // ), + // DocumentPermissionsWidget( + // document: state.document, + // ), + // ], + // ), ], ), ), @@ -299,13 +331,13 @@ class _DocumentDetailsPageState extends State { ), ), ), - ), - ), + ); + }, ), ); } - Widget _buildEditButton() { + Widget _buildEditButton(DocumentModel document) { final currentUser = context.watch(); bool canEdit = context.watchInternetConnection && @@ -313,7 +345,6 @@ class _DocumentDetailsPageState extends State { if (!canEdit) { return const SizedBox.shrink(); } - final document = context.read().state.document; return Tooltip( message: S.of(context)!.editDocumentTooltip, preferBelow: false, @@ -326,60 +357,80 @@ class _DocumentDetailsPageState extends State { ); } + Widget _buildErrorState() { + return SliverToBoxAdapter( + child: Center( + child: Text("Could not load document."), + ), + ); + } + + Widget _buildLoadingState() { + return SliverFillRemaining( + child: Center( + child: CircularProgressIndicator(), + ), + ); + } + BlocBuilder _buildBottomAppBar() { return BlocBuilder( builder: (context, state) { + final currentUser = context.watch(); return BottomAppBar( - child: BlocBuilder( - builder: (context, connectivityState) { - final currentUser = context.watch(); - return Row( - mainAxisAlignment: MainAxisAlignment.start, - children: [ - ConnectivityAwareActionWrapper( - disabled: !currentUser.paperlessUser.canDeleteDocuments, - offlineBuilder: (context, child) { - return const IconButton( - icon: Icon(Icons.delete), - onPressed: null, - ).paddedSymmetrically(horizontal: 4); - }, - child: IconButton( - tooltip: S.of(context)!.deleteDocumentTooltip, - icon: const Icon(Icons.delete), - onPressed: () => _onDelete(state.document), - ).paddedSymmetrically(horizontal: 4), + child: Builder( + builder: (context) { + return switch (state) { + DocumentDetailsLoaded(document: var document) => Row( + mainAxisAlignment: MainAxisAlignment.start, + children: [ + ConnectivityAwareActionWrapper( + disabled: !currentUser.paperlessUser.canDeleteDocuments, + offlineBuilder: (context, child) { + return const IconButton( + icon: Icon(Icons.delete), + onPressed: null, + ).paddedSymmetrically(horizontal: 4); + }, + child: IconButton( + tooltip: S.of(context)!.deleteDocumentTooltip, + icon: const Icon(Icons.delete), + onPressed: () => _onDelete(document), + ).paddedSymmetrically(horizontal: 4), + ), + ConnectivityAwareActionWrapper( + offlineBuilder: (context, child) => + const DocumentDownloadButton( + document: null, + enabled: false, + ), + child: DocumentDownloadButton( + document: document, + ), + ), + ConnectivityAwareActionWrapper( + offlineBuilder: (context, child) => const IconButton( + icon: Icon(Icons.open_in_new), + onPressed: null, + ), + child: IconButton( + tooltip: S.of(context)!.openInSystemViewer, + icon: const Icon(Icons.open_in_new), + onPressed: _onOpenFileInSystemViewer, + ).paddedOnly(right: 4.0), + ), + DocumentShareButton(document: document), + IconButton( + tooltip: S.of(context)!.print, + onPressed: () => context + .read() + .printDocument(), + icon: const Icon(Icons.print), + ), + ], ), - ConnectivityAwareActionWrapper( - offlineBuilder: (context, child) => - const DocumentDownloadButton( - document: null, - enabled: false, - ), - child: DocumentDownloadButton( - document: state.document, - ), - ), - ConnectivityAwareActionWrapper( - offlineBuilder: (context, child) => const IconButton( - icon: Icon(Icons.open_in_new), - onPressed: null, - ), - child: IconButton( - tooltip: S.of(context)!.openInSystemViewer, - icon: const Icon(Icons.open_in_new), - onPressed: _onOpenFileInSystemViewer, - ).paddedOnly(right: 4.0), - ), - DocumentShareButton(document: state.document), - IconButton( - tooltip: S.of(context)!.print, - onPressed: () => - context.read().printDocument(), - icon: const Icon(Icons.print), - ), - ], - ); + _ => SizedBox.shrink(), + }; }, ), ); @@ -423,11 +474,4 @@ class _DocumentDetailsPageState extends State { } } } - - Future _onOpen(DocumentModel document) async { - DocumentPreviewRoute( - $extra: document, - title: document.title, - ).push(context); - } } diff --git a/lib/features/document_details/view/widgets/archive_serial_number_field.dart b/lib/features/document_details/view/widgets/archive_serial_number_field.dart index 3aeaef7..1f420f7 100644 --- a/lib/features/document_details/view/widgets/archive_serial_number_field.dart +++ b/lib/features/document_details/view/widgets/archive_serial_number_field.dart @@ -50,11 +50,16 @@ class _ArchiveSerialNumberFieldState extends State { context.watch().paperlessUser.canEditDocuments; return BlocListener( listenWhen: (previous, current) => + previous is DocumentDetailsLoaded && + current is DocumentDetailsLoaded && previous.document.archiveSerialNumber != - current.document.archiveSerialNumber, + current.document.archiveSerialNumber, listener: (context, state) { - _asnEditingController.text = - state.document.archiveSerialNumber?.toString() ?? ''; + _asnEditingController.text = (state as DocumentDetailsLoaded) + .document + .archiveSerialNumber + ?.toString() ?? + ''; setState(() { _canUpdate = false; }); diff --git a/lib/features/document_details/view/widgets/document_content_widget.dart b/lib/features/document_details/view/widgets/document_content_widget.dart index a73dec3..b3c5be7 100644 --- a/lib/features/document_details/view/widgets/document_content_widget.dart +++ b/lib/features/document_details/view/widgets/document_content_widget.dart @@ -1,26 +1,37 @@ import 'package:flutter/material.dart'; -import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:paperless_api/paperless_api.dart'; import 'package:paperless_mobile/core/widgets/highlighted_text.dart'; import 'package:paperless_mobile/core/widgets/shimmer_placeholder.dart'; -import 'package:paperless_mobile/core/extensions/flutter_extensions.dart'; -import 'package:paperless_mobile/features/document_details/cubit/document_details_cubit.dart'; -import 'package:paperless_mobile/generated/l10n/app_localizations.dart'; class DocumentContentWidget extends StatelessWidget { - final bool isFullContentLoaded; - final String? queryString; final DocumentModel document; + final String? queryString; const DocumentContentWidget({ super.key, - required this.isFullContentLoaded, required this.document, this.queryString, }); @override Widget build(BuildContext context) { - final screenWidth = MediaQuery.sizeOf(context).width; + // if (document == null) { + // final widths = [.3, .8, .9, .7, .6, .4, .8, .8, .6, .4]; + // return SliverToBoxAdapter( + // child: ShimmerPlaceholder( + // child: Column( + // children: [ + // for (int i = 0; i < 10; i++) + // Container( + // width: MediaQuery.sizeOf(context).width * widths[i], + // height: 14, + // color: Colors.white, + // margin: EdgeInsets.symmetric(vertical: 4), + // ), + // ], + // ), + // ), + // ); + // } return SliverToBoxAdapter( child: Column( crossAxisAlignment: CrossAxisAlignment.start, @@ -31,21 +42,6 @@ class DocumentContentWidget extends StatelessWidget { style: Theme.of(context).textTheme.bodyMedium, caseSensitive: false, ), - if (!isFullContentLoaded) - ShimmerPlaceholder( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - for (var scale in [0.5, 0.9, 0.5, 0.8, 0.9, 0.9]) - Container( - margin: const EdgeInsets.symmetric(vertical: 4), - width: screenWidth * scale, - height: 14, - color: Colors.white, - ), - ], - ), - ).paddedOnly(top: 4), ], ), ); diff --git a/lib/features/document_details/view/widgets/document_meta_data_widget.dart b/lib/features/document_details/view/widgets/document_meta_data_widget.dart index 0d003ff..fb55bd7 100644 --- a/lib/features/document_details/view/widgets/document_meta_data_widget.dart +++ b/lib/features/document_details/view/widgets/document_meta_data_widget.dart @@ -4,87 +4,73 @@ import 'package:intl/intl.dart'; import 'package:paperless_api/paperless_api.dart'; import 'package:paperless_mobile/core/database/tables/local_user_account.dart'; import 'package:paperless_mobile/core/extensions/flutter_extensions.dart'; -import 'package:paperless_mobile/features/document_details/cubit/document_details_cubit.dart'; import 'package:paperless_mobile/features/document_details/view/widgets/archive_serial_number_field.dart'; import 'package:paperless_mobile/features/document_details/view/widgets/details_item.dart'; import 'package:paperless_mobile/generated/l10n/app_localizations.dart'; import 'package:paperless_mobile/helpers/format_helpers.dart'; -class DocumentMetaDataWidget extends StatefulWidget { +class DocumentMetaDataWidget extends StatelessWidget { final DocumentModel document; + final DocumentMetaData metaData; final double itemSpacing; const DocumentMetaDataWidget({ super.key, required this.document, + required this.metaData, required this.itemSpacing, }); - @override - State createState() => _DocumentMetaDataWidgetState(); -} - -class _DocumentMetaDataWidgetState extends State { @override Widget build(BuildContext context) { final currentUser = context.watch().paperlessUser; - return BlocBuilder( - builder: (context, state) { - if (state.metaData == null) { - return const SliverToBoxAdapter( - child: Center( - child: CircularProgressIndicator(), - ), - ); - } - return SliverList( - delegate: SliverChildListDelegate( - [ - if (currentUser.canEditDocuments) - ArchiveSerialNumberField( - document: widget.document, - ).paddedOnly(bottom: widget.itemSpacing), - DetailsItem.text( - DateFormat.yMMMMd(Localizations.localeOf(context).toString()) - .format(widget.document.modified), - context: context, - label: S.of(context)!.modifiedAt, - ).paddedOnly(bottom: widget.itemSpacing), - DetailsItem.text( - DateFormat.yMMMMd(Localizations.localeOf(context).toString()) - .format(widget.document.added), - context: context, - label: S.of(context)!.addedAt, - ).paddedOnly(bottom: widget.itemSpacing), - DetailsItem.text( - state.metaData!.mediaFilename, - context: context, - label: S.of(context)!.mediaFilename, - ).paddedOnly(bottom: widget.itemSpacing), - if (state.document.originalFileName != null) - DetailsItem.text( - state.document.originalFileName!, - context: context, - label: S.of(context)!.originalMD5Checksum, - ).paddedOnly(bottom: widget.itemSpacing), - DetailsItem.text( - state.metaData!.originalChecksum, - context: context, - label: S.of(context)!.originalMD5Checksum, - ).paddedOnly(bottom: widget.itemSpacing), - DetailsItem.text( - formatBytes(state.metaData!.originalSize, 2), - context: context, - label: S.of(context)!.originalFileSize, - ).paddedOnly(bottom: widget.itemSpacing), - DetailsItem.text( - state.metaData!.originalMimeType, - context: context, - label: S.of(context)!.originalMIMEType, - ).paddedOnly(bottom: widget.itemSpacing), - ], - ), - ); - }, + + return SliverList( + delegate: SliverChildListDelegate( + [ + if (currentUser.canEditDocuments) + ArchiveSerialNumberField( + document: document, + ).paddedOnly(bottom: itemSpacing), + DetailsItem.text( + DateFormat.yMMMMd(Localizations.localeOf(context).toString()) + .format(document.modified), + context: context, + label: S.of(context)!.modifiedAt, + ).paddedOnly(bottom: itemSpacing), + DetailsItem.text( + DateFormat.yMMMMd(Localizations.localeOf(context).toString()) + .format(document.added), + context: context, + label: S.of(context)!.addedAt, + ).paddedOnly(bottom: itemSpacing), + DetailsItem.text( + metaData.mediaFilename, + context: context, + label: S.of(context)!.mediaFilename, + ).paddedOnly(bottom: itemSpacing), + if (document.originalFileName != null) + DetailsItem.text( + document.originalFileName!, + context: context, + label: S.of(context)!.originalMD5Checksum, + ).paddedOnly(bottom: itemSpacing), + DetailsItem.text( + metaData.originalChecksum, + context: context, + label: S.of(context)!.originalMD5Checksum, + ).paddedOnly(bottom: itemSpacing), + DetailsItem.text( + formatBytes(metaData.originalSize, 2), + context: context, + label: S.of(context)!.originalFileSize, + ).paddedOnly(bottom: itemSpacing), + DetailsItem.text( + metaData.originalMimeType, + context: context, + label: S.of(context)!.originalMIMEType, + ).paddedOnly(bottom: itemSpacing), + ], + ), ); } } diff --git a/lib/features/document_details/view/widgets/document_overview_widget.dart b/lib/features/document_details/view/widgets/document_overview_widget.dart index d15d0c8..c6d255d 100644 --- a/lib/features/document_details/view/widgets/document_overview_widget.dart +++ b/lib/features/document_details/view/widgets/document_overview_widget.dart @@ -6,6 +6,7 @@ import 'package:paperless_mobile/core/database/tables/local_user_account.dart'; import 'package:paperless_mobile/core/repository/label_repository.dart'; import 'package:paperless_mobile/core/widgets/highlighted_text.dart'; import 'package:paperless_mobile/core/extensions/flutter_extensions.dart'; +import 'package:paperless_mobile/core/widgets/shimmer_placeholder.dart'; import 'package:paperless_mobile/features/document_details/view/widgets/details_item.dart'; import 'package:paperless_mobile/features/labels/tags/view/widgets/tags_widget.dart'; import 'package:paperless_mobile/features/labels/view/widgets/label_text.dart'; @@ -27,6 +28,7 @@ class DocumentOverviewWidget extends StatelessWidget { Widget build(BuildContext context) { final user = context.watch().paperlessUser; final availableLabels = context.watch().state; + return SliverList.list( children: [ if (document.title.isNotEmpty) diff --git a/lib/features/document_edit/cubit/document_edit_cubit.dart b/lib/features/document_edit/cubit/document_edit_cubit.dart index d6f28d2..850f41a 100644 --- a/lib/features/document_edit/cubit/document_edit_cubit.dart +++ b/lib/features/document_edit/cubit/document_edit_cubit.dart @@ -27,22 +27,6 @@ class DocumentEditCubit extends Cubit { emit(state.copyWith(document: doc)); } }); - _labelRepository.addListener( - this, - onChanged: (labels) { - if (isClosed) { - return; - } - emit( - state.copyWith( - correspondents: labels.correspondents, - documentTypes: labels.documentTypes, - storagePaths: labels.storagePaths, - tags: labels.tags, - ), - ); - }, - ); } Future updateDocument(DocumentModel document) async { @@ -76,7 +60,6 @@ class DocumentEditCubit extends Cubit { @override Future close() { _notifier.removeListener(this); - _labelRepository.removeListener(this); return super.close(); } } diff --git a/lib/features/document_edit/cubit/document_edit_state.dart b/lib/features/document_edit/cubit/document_edit_state.dart index 0f1bb39..bf3d4c2 100644 --- a/lib/features/document_edit/cubit/document_edit_state.dart +++ b/lib/features/document_edit/cubit/document_edit_state.dart @@ -5,9 +5,5 @@ class DocumentEditState with _$DocumentEditState { const factory DocumentEditState({ required DocumentModel document, FieldSuggestions? suggestions, - @Default({}) Map correspondents, - @Default({}) Map documentTypes, - @Default({}) Map storagePaths, - @Default({}) Map tags, }) = _DocumentEditState; } diff --git a/lib/features/document_edit/view/document_edit_page.dart b/lib/features/document_edit/view/document_edit_page.dart index c1205c4..2acf274 100644 --- a/lib/features/document_edit/view/document_edit_page.dart +++ b/lib/features/document_edit/view/document_edit_page.dart @@ -9,6 +9,7 @@ import 'package:go_router/go_router.dart'; import 'package:intl/intl.dart'; import 'package:paperless_api/paperless_api.dart'; import 'package:paperless_mobile/core/database/tables/local_user_account.dart'; +import 'package:paperless_mobile/core/repository/label_repository.dart'; import 'package:paperless_mobile/core/widgets/dialog_utils/dialog_cancel_button.dart'; import 'package:paperless_mobile/core/widgets/dialog_utils/pop_with_unsaved_changes.dart'; import 'package:paperless_mobile/core/widgets/form_builder_fields/form_builder_localized_date_picker.dart'; @@ -45,39 +46,7 @@ class _DocumentEditPageState extends State { @override Widget build(BuildContext context) { final currentUser = context.watch().paperlessUser; - return BlocConsumer( - listenWhen: (previous, current) => - previous.document.content != current.document.content, - listener: (context, state) { - final contentField = _formKey.currentState?.fields[fkContent]; - if (contentField == null) { - return; - } - if (contentField.isDirty) { - showDialog( - context: context, - builder: (context) => AlertDialog( - //TODO: INTL - title: Text("Content has changed!"), - content: Text( - "The content of this document has changed. This can happen if the full content was not yet loaded. By accepting the incoming changes, your changes will be overwritten and therefore lost! Do you want to discard your changes in favor of the full content?", - ), - actions: [ - DialogCancelButton(), - ElevatedButton( - onPressed: () { - contentField.didChange(state.document.content); - Navigator.of(context).pop(); - }, - child: Text(S.of(context)!.discard), - ), - ], - ), - ); - } else { - contentField.didChange(state.document.content); - } - }, + return BlocBuilder( builder: (context, state) { final filteredSuggestions = state.suggestions; return PopWithUnsavedChanges( @@ -160,7 +129,7 @@ class _DocumentEditPageState extends State { S.of(context)!.addCorrespondent, labelText: S.of(context)!.correspondent, options: context - .watch() + .watch() .state .correspondents, initialValue: state @@ -203,7 +172,10 @@ class _DocumentEditPageState extends State { ? SetIdQueryParameter( id: state.document.documentType!) : const UnsetIdQueryParameter(), - options: state.documentTypes, + options: context + .watch() + .state + .documentTypes, name: _DocumentEditPageState.fkDocumentType, prefixIcon: const Icon(Icons.description_outlined), @@ -230,7 +202,10 @@ class _DocumentEditPageState extends State { currentUser.canCreateStoragePaths, addLabelText: S.of(context)!.addStoragePath, labelText: S.of(context)!.storagePath, - options: state.storagePaths, + options: context + .watch() + .state + .storagePaths, initialValue: state.document.storagePath != null ? SetIdQueryParameter( @@ -246,7 +221,8 @@ class _DocumentEditPageState extends State { // Tag form field if (currentUser.canViewTags) TagsFormField( - options: state.tags, + options: + context.watch().state.tags, name: fkTags, allowOnlySelection: true, allowCreation: true, @@ -290,30 +266,6 @@ class _DocumentEditPageState extends State { ); } - bool _isFieldDirty(DocumentModel document) { - final fkState = _formKey.currentState; - if (fkState == null) { - return false; - } - fkState.save(); - final ( - title, - correspondent, - documentType, - storagePath, - tags, - createdAt, - content - ) = _currentValues; - return document.title != title || - document.correspondent != correspondent || - document.documentType != documentType || - document.storagePath != storagePath || - const UnorderedIterableEquality().equals(document.tags, tags) || - document.created != createdAt || - document.content != content; - } - ( String? title, int? correspondent, @@ -333,7 +285,7 @@ class _DocumentEditPageState extends State { fkState.getRawValue(fkStoragePath); final tagsParam = fkState.getRawValue(fkTags); final title = fkState.getRawValue(fkTitle); - final created = fkState.getRawValue(fkCreatedDate); + final created = fkState.getRawValue(fkCreatedDate); final correspondent = switch (correspondentParam) { SetIdQueryParameter(id: var id) => id, _ => null, @@ -358,7 +310,7 @@ class _DocumentEditPageState extends State { documentType, storagePath, tags, - created, + created?.toDateTime(), content ); } @@ -432,7 +384,7 @@ class _DocumentEditPageState extends State { DateFormat.yMMMMd(Localizations.localeOf(context).toString()) .format(itemData)), onPressed: () => _formKey.currentState?.fields[fkCreatedDate] - ?.didChange(itemData), + ?.didChange(FormDateTime.fromDateTime(itemData)), ), ), ], diff --git a/lib/features/document_search/view/document_search_page.dart b/lib/features/document_search/view/document_search_page.dart index 5f8c6bb..83b96dc 100644 --- a/lib/features/document_search/view/document_search_page.dart +++ b/lib/features/document_search/view/document_search_page.dart @@ -4,6 +4,7 @@ import 'dart:math' as math; import 'package:collection/collection.dart'; import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:paperless_mobile/core/extensions/document_extensions.dart'; import 'package:paperless_mobile/core/extensions/flutter_extensions.dart'; import 'package:paperless_mobile/features/document_search/cubit/document_search_cubit.dart'; import 'package:paperless_mobile/features/document_search/view/remove_history_entry_dialog.dart'; @@ -219,8 +220,12 @@ class _DocumentSearchPageState extends State { hasLoaded: state.hasLoaded, enableHeroAnimation: false, onTap: (document) { - DocumentDetailsRoute($extra: document, isLabelClickable: false) - .push(context); + DocumentDetailsRoute( + title: document.title, + id: document.id, + isLabelClickable: false, + thumbnailUrl: document.buildThumbnailUrl(context), + ).push(context); }, ) ], diff --git a/lib/features/document_upload/cubit/document_upload_cubit.dart b/lib/features/document_upload/cubit/document_upload_cubit.dart index bfe4f50..5bc09e3 100644 --- a/lib/features/document_upload/cubit/document_upload_cubit.dart +++ b/lib/features/document_upload/cubit/document_upload_cubit.dart @@ -1,7 +1,6 @@ import 'dart:async'; import 'package:bloc/bloc.dart'; -import 'package:equatable/equatable.dart'; import 'package:flutter/foundation.dart'; import 'package:paperless_api/paperless_api.dart'; import 'package:paperless_mobile/core/repository/label_repository.dart'; @@ -21,18 +20,7 @@ class DocumentUploadCubit extends Cubit { this._documentApi, this._connectivityStatusService, this._tasksNotifier, - ) : super(const DocumentUploadState()) { - _labelRepository.addListener( - this, - onChanged: (labels) { - emit(state.copyWith( - correspondents: labels.correspondents, - documentTypes: labels.documentTypes, - tags: labels.tags, - )); - }, - ); - } + ) : super(const DocumentUploadState()); Future upload( Uint8List bytes, { @@ -44,7 +32,6 @@ class DocumentUploadCubit extends Cubit { Iterable tags = const [], DateTime? createdAt, int? asn, - void Function(double)? onProgressChanged, }) async { final taskId = await _documentApi.create( bytes, @@ -55,17 +42,15 @@ class DocumentUploadCubit extends Cubit { tags: tags, createdAt: createdAt, asn: asn, - onProgressChanged: onProgressChanged, + onProgressChanged: (progress) { + if (!isClosed) { + emit(state.copyWith(uploadProgress: progress)); + } + }, ); if (taskId != null) { _tasksNotifier.listenToTaskChanges(taskId); } return taskId; } - - @override - Future close() async { - _labelRepository.removeListener(this); - return super.close(); - } } diff --git a/lib/features/document_upload/cubit/document_upload_state.dart b/lib/features/document_upload/cubit/document_upload_state.dart index 61b7fa5..15cab11 100644 --- a/lib/features/document_upload/cubit/document_upload_state.dart +++ b/lib/features/document_upload/cubit/document_upload_state.dart @@ -1,33 +1,17 @@ part of 'document_upload_cubit.dart'; @immutable -class DocumentUploadState extends Equatable { - final Map tags; - final Map correspondents; - final Map documentTypes; - +class DocumentUploadState { + final double? uploadProgress; const DocumentUploadState({ - this.tags = const {}, - this.correspondents = const {}, - this.documentTypes = const {}, + this.uploadProgress, }); - @override - List get props => [ - tags, - correspondents, - documentTypes, - ]; - DocumentUploadState copyWith({ - Map? tags, - Map? correspondents, - Map? documentTypes, + double? uploadProgress, }) { return DocumentUploadState( - tags: tags ?? this.tags, - correspondents: correspondents ?? this.correspondents, - documentTypes: documentTypes ?? this.documentTypes, + uploadProgress: uploadProgress ?? this.uploadProgress, ); } } diff --git a/lib/features/document_upload/view/document_upload_preparation_page.dart b/lib/features/document_upload/view/document_upload_preparation_page.dart index 959d914..07f4c41 100644 --- a/lib/features/document_upload/view/document_upload_preparation_page.dart +++ b/lib/features/document_upload/view/document_upload_preparation_page.dart @@ -6,28 +6,24 @@ import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:flutter_form_builder/flutter_form_builder.dart'; import 'package:go_router/go_router.dart'; import 'package:hive/hive.dart'; -import 'package:intl/date_symbol_data_local.dart'; import 'package:intl/intl.dart'; import 'package:paperless_api/paperless_api.dart'; import 'package:paperless_mobile/core/database/hive/hive_config.dart'; import 'package:paperless_mobile/core/database/tables/global_settings.dart'; import 'package:paperless_mobile/core/database/tables/local_user_account.dart'; -import 'package:paperless_mobile/features/logging/data/logger.dart'; -import 'package:paperless_mobile/core/repository/label_repository.dart'; -import 'package:paperless_mobile/core/widgets/future_or_builder.dart'; import 'package:paperless_mobile/core/extensions/flutter_extensions.dart'; +import 'package:paperless_mobile/core/repository/label_repository.dart'; +import 'package:paperless_mobile/core/widgets/form_builder_fields/form_builder_localized_date_picker.dart'; +import 'package:paperless_mobile/core/widgets/future_or_builder.dart'; import 'package:paperless_mobile/features/document_upload/cubit/document_upload_cubit.dart'; -import 'package:paperless_mobile/features/edit_label/view/impl/add_correspondent_page.dart'; -import 'package:paperless_mobile/features/edit_label/view/impl/add_document_type_page.dart'; -import 'package:paperless_mobile/features/home/view/model/api_version.dart'; import 'package:paperless_mobile/features/labels/tags/view/widgets/tags_form_field.dart'; import 'package:paperless_mobile/features/labels/view/widgets/label_form_field.dart'; +import 'package:paperless_mobile/features/logging/data/logger.dart'; import 'package:paperless_mobile/features/sharing/view/widgets/file_thumbnail.dart'; import 'package:paperless_mobile/generated/l10n/app_localizations.dart'; import 'package:paperless_mobile/helpers/message_helpers.dart'; import 'package:paperless_mobile/routes/typed/branches/labels_route.dart'; import 'package:paperless_mobile/routes/typed/shells/authenticated_route.dart'; -import 'package:provider/provider.dart'; class DocumentUploadResult { final bool success; @@ -62,7 +58,6 @@ class _DocumentUploadPreparationPageState final GlobalKey _formKey = GlobalKey(); Map _errors = {}; - bool _isUploadLoading = false; late bool _syncTitleAndFilename; bool _showDatePickerDeleteIcon = false; final _now = DateTime.now(); @@ -75,21 +70,32 @@ class _DocumentUploadPreparationPageState @override Widget build(BuildContext context) { - return Scaffold( - extendBodyBehindAppBar: false, - resizeToAvoidBottomInset: true, - floatingActionButton: Visibility( - visible: MediaQuery.of(context).viewInsets.bottom == 0, - child: FloatingActionButton.extended( - heroTag: "fab_document_upload", - onPressed: _onSubmit, - label: Text(S.of(context)!.upload), - icon: const Icon(Icons.upload), - ), - ), - body: BlocBuilder( - builder: (context, state) { - return FormBuilder( + final labels = context.watch().state; + return BlocBuilder( + builder: (context, state) { + return Scaffold( + extendBodyBehindAppBar: false, + resizeToAvoidBottomInset: true, + floatingActionButton: Visibility( + visible: MediaQuery.of(context).viewInsets.bottom == 0, + child: FloatingActionButton.extended( + heroTag: "fab_document_upload", + onPressed: state.uploadProgress == null ? _onSubmit : null, + label: state.uploadProgress == null + ? Text(S.of(context)!.upload) + : Text("Uploading..."), //TODO: INTL + icon: state.uploadProgress == null + ? const Icon(Icons.upload) + : SizedBox( + height: 24, + width: 24, + child: CircularProgressIndicator( + strokeWidth: 3, + value: state.uploadProgress, + )).padded(4), + ), + ), + body: FormBuilder( key: _formKey, child: NestedScrollView( headerSliverBuilder: (context, innerBoxIsScrolled) => [ @@ -97,7 +103,7 @@ class _DocumentUploadPreparationPageState handle: NestedScrollView.sliverOverlapAbsorberHandleFor(context), sliver: SliverAppBar( - leading: BackButton(), + leading: const BackButton(), pinned: true, expandedHeight: 150, flexibleSpace: FlexibleSpaceBar( @@ -105,7 +111,7 @@ class _DocumentUploadPreparationPageState future: widget.fileBytes, builder: (context, snapshot) { if (!snapshot.hasData) { - return SizedBox.shrink(); + return const SizedBox.shrink(); } return FileThumbnail( bytes: snapshot.data!, @@ -117,12 +123,6 @@ class _DocumentUploadPreparationPageState title: Text(S.of(context)!.prepareDocument), collapseMode: CollapseMode.pin, ), - bottom: _isUploadLoading - ? PreferredSize( - child: LinearProgressIndicator(), - preferredSize: Size.fromHeight(4.0), - ) - : null, ), ), ], @@ -219,32 +219,13 @@ class _DocumentUploadPreparationPageState ), ), // Created at - FormBuilderDateTimePicker( - autovalidateMode: AutovalidateMode.always, - format: DateFormat.yMMMMd( - Localizations.localeOf(context).toString()), - inputType: InputType.date, + FormBuilderLocalizedDatePicker( name: DocumentModel.createdKey, - initialValue: null, - onChanged: (value) { - setState(() => - _showDatePickerDeleteIcon = value != null); - }, - decoration: InputDecoration( - prefixIcon: - const Icon(Icons.calendar_month_outlined), - labelText: S.of(context)!.createdAt + " *", - suffixIcon: _showDatePickerDeleteIcon - ? IconButton( - icon: const Icon(Icons.close), - onPressed: () { - _formKey.currentState! - .fields[DocumentModel.createdKey] - ?.didChange(null); - }, - ) - : null, - ), + firstDate: DateTime(1970, 1, 1), + lastDate: DateTime.now(), + locale: Localizations.localeOf(context), + labelText: S.of(context)!.createdAt + " *", + allowUnset: true, ), // Correspondent if (context @@ -261,7 +242,7 @@ class _DocumentUploadPreparationPageState addLabelText: S.of(context)!.addCorrespondent, labelText: S.of(context)!.correspondent + " *", name: DocumentModel.correspondentKey, - options: state.correspondents, + options: labels.correspondents, prefixIcon: const Icon(Icons.person_outline), allowSelectUnassigned: true, canCreateNewLabel: context @@ -284,7 +265,7 @@ class _DocumentUploadPreparationPageState addLabelText: S.of(context)!.addDocumentType, labelText: S.of(context)!.documentType + " *", name: DocumentModel.documentTypeKey, - options: state.documentTypes, + options: labels.documentTypes, prefixIcon: const Icon(Icons.description_outlined), allowSelectUnassigned: true, @@ -302,7 +283,7 @@ class _DocumentUploadPreparationPageState allowCreation: true, allowExclude: false, allowOnlySelection: true, - options: state.tags, + options: labels.tags, ), Text( "* " + S.of(context)!.uploadInferValuesHint, @@ -318,9 +299,9 @@ class _DocumentUploadPreparationPageState ), ), ), - ); - }, - ), + ), + ); + }, ); } @@ -328,7 +309,6 @@ class _DocumentUploadPreparationPageState if (_formKey.currentState?.saveAndValidate() ?? false) { final cubit = context.read(); try { - setState(() => _isUploadLoading = true); final formValues = _formKey.currentState!.value; final correspondentParam = @@ -336,7 +316,7 @@ class _DocumentUploadPreparationPageState final docTypeParam = formValues[DocumentModel.documentTypeKey] as IdQueryParameter?; final tagsParam = formValues[DocumentModel.tagsKey] as TagsQuery?; - final createdAt = formValues[DocumentModel.createdKey] as DateTime?; + final createdAt = formValues[DocumentModel.createdKey] as FormDateTime?; final title = formValues[DocumentModel.titleKey] as String; final correspondent = switch (correspondentParam) { SetIdQueryParameter(id: var id) => id, @@ -365,7 +345,7 @@ class _DocumentUploadPreparationPageState documentType: docType, correspondent: correspondent, tags: tags, - createdAt: createdAt, + createdAt: createdAt?.toDateTime(), asn: asn, ); showSnackBar( @@ -390,10 +370,6 @@ class _DocumentUploadPreparationPageState const PaperlessApiException.unknown(), stackTrace, ); - } finally { - setState(() { - _isUploadLoading = false; - }); } } } diff --git a/lib/features/documents/cubit/documents_cubit.dart b/lib/features/documents/cubit/documents_cubit.dart index 3cf8716..3103b57 100644 --- a/lib/features/documents/cubit/documents_cubit.dart +++ b/lib/features/documents/cubit/documents_cubit.dart @@ -5,7 +5,7 @@ import 'package:hydrated_bloc/hydrated_bloc.dart'; import 'package:json_annotation/json_annotation.dart'; import 'package:paperless_api/paperless_api.dart'; import 'package:paperless_mobile/core/database/tables/local_user_app_state.dart'; -import 'package:paperless_mobile/core/extensions/document_iterable_extensions.dart'; +import 'package:paperless_mobile/core/extensions/document_extensions.dart'; import 'package:paperless_mobile/core/notifier/document_changed_notifier.dart'; import 'package:paperless_mobile/core/repository/label_repository.dart'; import 'package:paperless_mobile/core/service/connectivity_status_service.dart'; diff --git a/lib/features/documents/view/pages/documents_page.dart b/lib/features/documents/view/pages/documents_page.dart index ce3f8e9..7e7fd99 100644 --- a/lib/features/documents/view/pages/documents_page.dart +++ b/lib/features/documents/view/pages/documents_page.dart @@ -6,6 +6,7 @@ import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:paperless_api/paperless_api.dart'; import 'package:paperless_mobile/core/bloc/connectivity_cubit.dart'; import 'package:paperless_mobile/core/database/tables/local_user_account.dart'; +import 'package:paperless_mobile/core/extensions/document_extensions.dart'; import 'package:paperless_mobile/core/extensions/flutter_extensions.dart'; import 'package:paperless_mobile/features/app_drawer/view/app_drawer.dart'; import 'package:paperless_mobile/features/document_search/view/sliver_search_bar.dart'; @@ -404,7 +405,11 @@ class _DocumentsPageState extends State { return SliverAdaptiveDocumentsView( viewType: state.viewType, onTap: (document) { - DocumentDetailsRoute($extra: document).push(context); + DocumentDetailsRoute( + title: document.title, + id: document.id, + thumbnailUrl: document.buildThumbnailUrl(context), + ).push(context); }, onSelected: context.read().toggleDocumentSelection, diff --git a/lib/features/documents/view/widgets/document_preview.dart b/lib/features/documents/view/widgets/document_preview.dart index 6bc7e6e..c3633b5 100644 --- a/lib/features/documents/view/widgets/document_preview.dart +++ b/lib/features/documents/view/widgets/document_preview.dart @@ -9,7 +9,8 @@ import 'package:provider/provider.dart'; import 'package:shimmer/shimmer.dart'; class DocumentPreview extends StatelessWidget { - final DocumentModel document; + final int documentId; + final String? title; final BoxFit fit; final Alignment alignment; final double borderRadius; @@ -19,13 +20,14 @@ class DocumentPreview extends StatelessWidget { const DocumentPreview({ super.key, - required this.document, + required this.documentId, this.fit = BoxFit.cover, this.alignment = Alignment.topCenter, this.borderRadius = 12.0, this.enableHero = true, this.scale = 1.1, this.isClickable = true, + this.title, }); @override @@ -34,12 +36,12 @@ class DocumentPreview extends StatelessWidget { child: GestureDetector( behavior: HitTestBehavior.translucent, onTap: isClickable - ? () => DocumentPreviewRoute($extra: document).push(context) + ? () => DocumentPreviewRoute(id: documentId).push(context) : null, child: Builder(builder: (context) { if (enableHero) { return Hero( - tag: "thumb_${document.id}", + tag: "thumb_$documentId", child: _buildPreview(context), ); } @@ -57,10 +59,9 @@ class DocumentPreview extends StatelessWidget { child: CachedNetworkImage( fit: fit, alignment: alignment, - cacheKey: "thumb_${document.id}", - imageUrl: context - .read() - .getThumbnailUrl(document.id), + cacheKey: "thumb_$documentId", + imageUrl: + context.read().getThumbnailUrl(documentId), errorWidget: (ctxt, msg, __) => Text(msg), placeholder: (context, value) => Shimmer.fromColors( baseColor: Colors.grey[300]!, diff --git a/lib/features/documents/view/widgets/items/document_detailed_item.dart b/lib/features/documents/view/widgets/items/document_detailed_item.dart index b0388bd..127457d 100644 --- a/lib/features/documents/view/widgets/items/document_detailed_item.dart +++ b/lib/features/documents/view/widgets/items/document_detailed_item.dart @@ -1,5 +1,6 @@ import 'dart:math'; +import 'package:flutter/gestures.dart'; import 'package:flutter/material.dart'; import 'package:flutter_html/flutter_html.dart'; import 'package:hive_flutter/adapters.dart'; @@ -56,6 +57,7 @@ class DocumentDetailedItem extends DocumentItem { final maxHeight = highlights != null ? min(600.0, availableHeight) : min(500.0, availableHeight); + final labels = context.watch().state; return Card( color: isSelected ? Theme.of(context).colorScheme.inversePrimary : null, child: InkWell( @@ -79,39 +81,71 @@ class DocumentDetailedItem extends DocumentItem { width: double.infinity, height: maxHeight / 2, ), - child: DocumentPreview( - document: document, - fit: BoxFit.cover, - alignment: Alignment.topCenter, + child: Stack( + fit: StackFit.expand, + children: [ + DocumentPreview( + documentId: document.id, + title: document.title, + ), + if (paperlessUser.canViewTags) + Align( + alignment: Alignment.bottomLeft, + child: TagsWidget( + tags: + document.tags.map((e) => labels.tags[e]!).toList(), + onTagSelected: onTagSelected, + ).padded(), + ), + ], ), ), Row( mainAxisAlignment: MainAxisAlignment.spaceBetween, + crossAxisAlignment: CrossAxisAlignment.start, children: [ - Text( - DateFormat.yMMMMd(Localizations.localeOf(context).toString()) - .format(document.created), - style: Theme.of(context) - .textTheme - .bodySmall - ?.apply(color: Theme.of(context).hintColor), + Expanded( + child: RichText( + maxLines: 1, + overflow: TextOverflow.ellipsis, + text: TextSpan( + style: Theme.of(context) + .textTheme + .bodySmall + ?.apply(color: Theme.of(context).hintColor), + text: DateFormat.yMMMMd( + Localizations.localeOf(context).toString()) + .format(document.created), + children: [ + if (paperlessUser.canViewDocumentTypes && + document.documentType != null) ...[ + const TextSpan(text: '\u30FB'), + TextSpan( + text: labels + .documentTypes[document.documentType]?.name, + recognizer: onDocumentTypeSelected != null + ? (TapGestureRecognizer() + ..onTap = () => onDocumentTypeSelected!( + document.documentType)) + : null, + ), + ], + ], + ), + ), ), if (document.archiveSerialNumber != null) - Row( - children: [ - Text( - '#${document.archiveSerialNumber}', - style: Theme.of(context) - .textTheme - .bodySmall - ?.apply(color: Theme.of(context).hintColor), - ), - ], + Text( + '#${document.archiveSerialNumber}', + style: Theme.of(context) + .textTheme + .bodySmall + ?.apply(color: Theme.of(context).hintColor), ), ], ).paddedLTRB(8, 8, 8, 4), Text( - document.title.isEmpty ? '-' : document.title, + document.title.isEmpty ? '(-)' : document.title, style: Theme.of(context).textTheme.titleMedium, maxLines: 2, overflow: TextOverflow.ellipsis, @@ -128,39 +162,11 @@ class DocumentDetailedItem extends DocumentItem { textStyle: Theme.of(context).textTheme.titleSmall?.apply( color: Theme.of(context).colorScheme.onSurfaceVariant, ), - correspondent: context - .watch() - .state - .correspondents[document.correspondent], + correspondent: + labels.correspondents[document.correspondent], ), ], - ).paddedLTRB(8, 0, 8, 4), - if (paperlessUser.canViewDocumentTypes) - Row( - children: [ - const Icon( - Icons.description_outlined, - size: 16, - ).paddedOnly(right: 4.0), - DocumentTypeWidget( - onSelected: onDocumentTypeSelected, - textStyle: Theme.of(context).textTheme.titleSmall?.apply( - color: Theme.of(context).colorScheme.onSurfaceVariant, - ), - documentType: context - .watch() - .state - .documentTypes[document.documentType], - ), - ], - ).paddedLTRB(8, 0, 8, 4), - if (paperlessUser.canViewTags) - TagsWidget( - tags: document.tags - .map((e) => context.watch().state.tags[e]!) - .toList(), - onTagSelected: onTagSelected, - ).padded(), + ).paddedLTRB(8, 0, 8, 8), if (highlights != null) Html( data: '

${highlights!}

', diff --git a/lib/features/documents/view/widgets/items/document_grid_item.dart b/lib/features/documents/view/widgets/items/document_grid_item.dart index 1293265..b727f3a 100644 --- a/lib/features/documents/view/widgets/items/document_grid_item.dart +++ b/lib/features/documents/view/widgets/items/document_grid_item.dart @@ -49,7 +49,7 @@ class DocumentGridItem extends DocumentItem { children: [ Positioned.fill( child: DocumentPreview( - document: document, + documentId: document.id, borderRadius: 12.0, enableHero: enableHeroAnimation, ), diff --git a/lib/features/documents/view/widgets/items/document_list_item.dart b/lib/features/documents/view/widgets/items/document_list_item.dart index 5a5e1d5..2767288 100644 --- a/lib/features/documents/view/widgets/items/document_list_item.dart +++ b/lib/features/documents/view/widgets/items/document_list_item.dart @@ -75,31 +75,34 @@ class DocumentListItem extends DocumentItem { ), ], ), - subtitle: Padding( - padding: const EdgeInsets.symmetric(vertical: 4), - child: RichText( - maxLines: 1, - overflow: TextOverflow.ellipsis, - text: TextSpan( - text: DateFormat.yMMMMd(Localizations.localeOf(context).toString()) - .format(document.created), - style: Theme.of(context) - .textTheme - .labelSmall - ?.apply(color: Colors.grey), - children: document.documentType != null - ? [ - const TextSpan(text: '\u30FB'), - TextSpan( - text: labels.documentTypes[document.documentType]?.name, - recognizer: onDocumentTypeSelected != null - ? (TapGestureRecognizer() - ..onTap = () => - onDocumentTypeSelected!(document.documentType)) - : null, - ), - ] - : null, + subtitle: IntrinsicWidth( + child: Padding( + padding: const EdgeInsets.symmetric(vertical: 4), + child: RichText( + maxLines: 1, + overflow: TextOverflow.ellipsis, + text: TextSpan( + text: + DateFormat.yMMMMd(Localizations.localeOf(context).toString()) + .format(document.created), + style: Theme.of(context) + .textTheme + .labelSmall + ?.apply(color: Colors.grey), + children: document.documentType != null + ? [ + const TextSpan(text: '\u30FB'), + TextSpan( + text: labels.documentTypes[document.documentType]?.name, + recognizer: onDocumentTypeSelected != null + ? (TapGestureRecognizer() + ..onTap = () => onDocumentTypeSelected!( + document.documentType)) + : null, + ), + ] + : null, + ), ), ), ), @@ -108,7 +111,7 @@ class DocumentListItem extends DocumentItem { aspectRatio: _a4AspectRatio, child: GestureDetector( child: DocumentPreview( - document: document, + documentId: document.id, fit: BoxFit.cover, alignment: Alignment.topCenter, enableHero: enableHeroAnimation, diff --git a/lib/features/inbox/view/widgets/inbox_item.dart b/lib/features/inbox/view/widgets/inbox_item.dart index aa2ee52..3b4c569 100644 --- a/lib/features/inbox/view/widgets/inbox_item.dart +++ b/lib/features/inbox/view/widgets/inbox_item.dart @@ -3,6 +3,7 @@ import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:paperless_api/paperless_api.dart'; import 'package:paperless_mobile/core/database/tables/local_user_account.dart'; +import 'package:paperless_mobile/core/extensions/document_extensions.dart'; import 'package:paperless_mobile/core/widgets/shimmer_placeholder.dart'; import 'package:paperless_mobile/core/workarounds/colored_chip.dart'; import 'package:paperless_mobile/core/extensions/flutter_extensions.dart'; @@ -153,7 +154,9 @@ class _InboxItemState extends State { behavior: HitTestBehavior.translucent, onTap: () { DocumentDetailsRoute( - $extra: widget.document, + title: widget.document.title, + id: widget.document.id, + thumbnailUrl: widget.document.buildThumbnailUrl(context), isLabelClickable: false, ).push(context); }, @@ -168,7 +171,8 @@ class _InboxItemState extends State { AspectRatio( aspectRatio: InboxItem.a4AspectRatio, child: DocumentPreview( - document: widget.document, + documentId: widget.document.id, + title: widget.document.title, fit: BoxFit.cover, alignment: Alignment.topCenter, enableHero: false, diff --git a/lib/features/linked_documents/view/linked_documents_page.dart b/lib/features/linked_documents/view/linked_documents_page.dart index b7c4428..4068b13 100644 --- a/lib/features/linked_documents/view/linked_documents_page.dart +++ b/lib/features/linked_documents/view/linked_documents_page.dart @@ -1,6 +1,7 @@ import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:paperless_mobile/core/bloc/connectivity_cubit.dart'; +import 'package:paperless_mobile/core/extensions/document_extensions.dart'; import 'package:paperless_mobile/features/documents/view/widgets/adaptive_documents_view.dart'; import 'package:paperless_mobile/features/documents/view/widgets/selection/view_type_selection_widget.dart'; import 'package:paperless_mobile/features/linked_documents/cubit/linked_documents_cubit.dart'; @@ -53,8 +54,10 @@ class _LinkedDocumentsPageState extends State hasLoaded: state.hasLoaded, onTap: (document) { DocumentDetailsRoute( - $extra: document, + title: document.title, + id: document.id, isLabelClickable: false, + thumbnailUrl: document.buildThumbnailUrl(context), ).push(context); }, ), diff --git a/lib/features/saved_view_details/cubit/saved_view_preview_cubit.dart b/lib/features/saved_view_details/cubit/saved_view_preview_cubit.dart index e762e45..29b0bbe 100644 --- a/lib/features/saved_view_details/cubit/saved_view_preview_cubit.dart +++ b/lib/features/saved_view_details/cubit/saved_view_preview_cubit.dart @@ -1,7 +1,7 @@ import 'package:bloc/bloc.dart'; import 'package:collection/collection.dart'; import 'package:paperless_api/paperless_api.dart'; -import 'package:paperless_mobile/core/extensions/document_iterable_extensions.dart'; +import 'package:paperless_mobile/core/extensions/document_extensions.dart'; import 'package:paperless_mobile/core/notifier/document_changed_notifier.dart'; import 'package:paperless_mobile/core/service/connectivity_status_service.dart'; diff --git a/lib/features/saved_view_details/view/saved_view_preview.dart b/lib/features/saved_view_details/view/saved_view_preview.dart index 49f5ff9..324a15b 100644 --- a/lib/features/saved_view_details/view/saved_view_preview.dart +++ b/lib/features/saved_view_details/view/saved_view_preview.dart @@ -1,6 +1,7 @@ import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:paperless_api/paperless_api.dart'; +import 'package:paperless_mobile/core/extensions/document_extensions.dart'; import 'package:paperless_mobile/core/extensions/flutter_extensions.dart'; import 'package:paperless_mobile/features/documents/cubit/documents_cubit.dart'; import 'package:paperless_mobile/features/documents/view/widgets/items/document_list_item.dart'; @@ -55,8 +56,12 @@ class SavedViewPreview extends StatelessWidget { isSelected: false, isSelectionActive: false, onTap: (document) { - DocumentDetailsRoute($extra: document) - .push(context); + DocumentDetailsRoute( + title: document.title, + id: document.id, + thumbnailUrl: + document.buildThumbnailUrl(context), + ).push(context); }, onSelected: null, ), diff --git a/lib/features/similar_documents/view/similar_documents_view.dart b/lib/features/similar_documents/view/similar_documents_view.dart index ffaa844..4f9c14b 100644 --- a/lib/features/similar_documents/view/similar_documents_view.dart +++ b/lib/features/similar_documents/view/similar_documents_view.dart @@ -2,6 +2,7 @@ import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:paperless_api/paperless_api.dart'; import 'package:paperless_mobile/core/bloc/connectivity_cubit.dart'; +import 'package:paperless_mobile/core/extensions/document_extensions.dart'; import 'package:paperless_mobile/core/widgets/offline_widget.dart'; import 'package:paperless_mobile/features/documents/view/widgets/adaptive_documents_view.dart'; import 'package:paperless_mobile/features/paged_document_view/view/document_paging_view_mixin.dart'; @@ -66,7 +67,9 @@ class _SimilarDocumentsViewState extends State enableHeroAnimation: false, onTap: (document) { DocumentDetailsRoute( - $extra: document, + title: document.title, + id: document.id, + thumbnailUrl: document.buildThumbnailUrl(context), isLabelClickable: false, ).push(context); }, diff --git a/lib/routes/typed/branches/documents_route.dart b/lib/routes/typed/branches/documents_route.dart index 8fc0640..755bc25 100644 --- a/lib/routes/typed/branches/documents_route.dart +++ b/lib/routes/typed/branches/documents_route.dart @@ -14,7 +14,6 @@ import 'package:paperless_mobile/features/documents/view/pages/document_view.dar import 'package:paperless_mobile/features/documents/view/pages/documents_page.dart'; import 'package:paperless_mobile/generated/l10n/app_localizations.dart'; import 'package:paperless_mobile/routes/navigation_keys.dart'; -import 'package:paperless_mobile/routes/routes.dart'; import 'package:paperless_mobile/theme.dart'; class DocumentsBranch extends StatefulShellBranchData { @@ -33,14 +32,18 @@ class DocumentDetailsRoute extends GoRouteData { static final GlobalKey $parentNavigatorKey = outerShellNavigatorKey; + final int id; final bool isLabelClickable; - final DocumentModel $extra; final String? queryString; - + final String? thumbnailUrl; + final String? title; + const DocumentDetailsRoute({ - required this.$extra, + required this.id, this.isLabelClickable = true, this.queryString, + this.thumbnailUrl, + this.title, }); @override @@ -51,14 +54,15 @@ class DocumentDetailsRoute extends GoRouteData { context.read(), context.read(), context.read(), - initialDocument: $extra, - ) - ..loadFullContent() - ..loadMetaData(), + id: id, + )..initialize(), lazy: false, child: DocumentDetailsPage( + id: id, isLabelClickable: isLabelClickable, titleAndContentQueryString: queryString, + thumbnailUrl: thumbnailUrl, + title: title, ), ); } @@ -96,20 +100,19 @@ class EditDocumentRoute extends GoRouteData { class DocumentPreviewRoute extends GoRouteData { static final GlobalKey $parentNavigatorKey = outerShellNavigatorKey; - - final DocumentModel $extra; + final int id; final String? title; const DocumentPreviewRoute({ - required this.$extra, + required this.id, this.title, }); @override Widget build(BuildContext context, GoRouterState state) { return DocumentView( - documentBytes: context.read().download($extra), - title: title ?? $extra.title, + documentBytes: context.read().downloadDocument(id), + title: title, ); } } diff --git a/lib/routes/typed/shells/authenticated_route.dart b/lib/routes/typed/shells/authenticated_route.dart index 5ad17f4..3762cbe 100644 --- a/lib/routes/typed/shells/authenticated_route.dart +++ b/lib/routes/typed/shells/authenticated_route.dart @@ -68,7 +68,7 @@ part 'authenticated_route.g.dart'; path: "/documents", routes: [ TypedGoRoute( - path: "details", + path: "details/:id", name: R.documentDetails, ), TypedGoRoute( diff --git a/packages/paperless_api/lib/src/modules/documents_api/paperless_documents_api.dart b/packages/paperless_api/lib/src/modules/documents_api/paperless_documents_api.dart index ccbad6d..106fef1 100644 --- a/packages/paperless_api/lib/src/modules/documents_api/paperless_documents_api.dart +++ b/packages/paperless_api/lib/src/modules/documents_api/paperless_documents_api.dart @@ -21,11 +21,11 @@ abstract class PaperlessDocumentsApi { Future> findAll(DocumentFilter filter); Future find(int id); Future delete(DocumentModel doc); - Future getMetaData(DocumentModel document); + Future getMetaData(int id); Future> bulkAction(BulkAction action); Future getPreview(int docId); String getThumbnailUrl(int docId); - Future download(DocumentModel document, {bool original}); + Future downloadDocument(int id, {bool original}); Future downloadToFile( DocumentModel document, String localFilePath, { diff --git a/packages/paperless_api/lib/src/modules/documents_api/paperless_documents_api_impl.dart b/packages/paperless_api/lib/src/modules/documents_api/paperless_documents_api_impl.dart index cbc62b2..4df3d92 100644 --- a/packages/paperless_api/lib/src/modules/documents_api/paperless_documents_api_impl.dart +++ b/packages/paperless_api/lib/src/modules/documents_api/paperless_documents_api_impl.dart @@ -200,13 +200,13 @@ class PaperlessDocumentsApiImpl implements PaperlessDocumentsApi { } @override - Future download( - DocumentModel document, { + Future downloadDocument( + int id, { bool original = false, }) async { try { final response = await client.get( - "/api/documents/${document.id}/download/", + "/api/documents/$id/download/", queryParameters: {'original': original}, options: Options(responseType: ResponseType.bytes), ); @@ -242,14 +242,20 @@ class PaperlessDocumentsApiImpl implements PaperlessDocumentsApi { } @override - Future getMetaData(DocumentModel document) async { + Future getMetaData(int id) async { + debugPrint("Fetching data for /api/documents/$id/metadata/..."); + try { - final response = - await client.get("/api/documents/${document.id}/metadata/"); - return compute( - DocumentMetaData.fromJson, - response.data as Map, + final response = await client.get( + "/api/documents/$id/metadata/", + options: Options( + sendTimeout: Duration(seconds: 10), + receiveTimeout: Duration(seconds: 10), + ), ); + debugPrint("Fetched data for /api/documents/$id/metadata/."); + + return DocumentMetaData.fromJson(response.data); } on DioException catch (exception) { throw exception.unravel( orElse: const PaperlessApiException.unknown(), @@ -296,11 +302,17 @@ class PaperlessDocumentsApiImpl implements PaperlessDocumentsApi { @override Future find(int id) async { + debugPrint("Fetching data from /api/documents/$id/..."); try { final response = await client.get( "/api/documents/$id/", - options: Options(validateStatus: (status) => status == 200), + options: Options( + validateStatus: (status) => status == 200, + sendTimeout: Duration(seconds: 10), + receiveTimeout: Duration(seconds: 10), + ), ); + debugPrint("Fetched data for /api/documents/$id/."); return DocumentModel.fromJson(response.data); } on DioException catch (exception) { throw exception.unravel( From cb4839f5a3558390dbc4745b0ddacc79dbdbd494 Mon Sep 17 00:00:00 2001 From: Anton Stubenbord Date: Tue, 24 Oct 2023 15:37:43 +0200 Subject: [PATCH 6/6] feat: Update translations, add pdf view to document edit page --- .../metadata/android/de-DE/changelogs/54.txt | 4 + .../metadata/android/en-US/changelogs/54.txt | 4 + .../dio_http_error_interceptor.dart | 2 + .../notifier/document_changed_notifier.dart | 5 +- .../form_builder_localized_date_picker.dart | 7 +- lib/features/app_drawer/view/app_drawer.dart | 16 - .../changelogs/view/changelog_dialog.dart | 1 + .../cubit/document_details_cubit.dart | 15 +- .../cubit/document_edit_cubit.dart | 10 +- .../view/document_edit_page.dart | 419 ++++++++++-------- .../documents/view/pages/document_view.dart | 79 ++-- .../documents/view/pages/documents_page.dart | 5 +- .../view/widgets/adaptive_documents_view.dart | 5 +- .../date_and_document_type_widget.dart | 55 +++ .../widgets/items/document_detailed_item.dart | 70 +-- .../widgets/items/document_grid_item.dart | 219 +++++---- .../widgets/items/document_list_item.dart | 39 +- .../view/widgets/correspondent_widget.dart | 22 +- .../view/widgets/document_type_widget.dart | 20 +- lib/features/logging/view/app_logs_page.dart | 4 +- lib/features/login/view/add_account_page.dart | 70 +-- .../user_credentials_form_field.dart | 109 +++-- .../cubit/document_paging_bloc_mixin.dart | 5 +- lib/features/settings/view/settings_page.dart | 5 + .../settings/view/widgets/app_logs_tile.dart | 18 + .../view/widgets/changelogs_tile.dart | 18 + .../widgets/language_selection_setting.dart | 2 +- .../cubit/similar_documents_cubit.dart | 13 +- .../cubit/similar_documents_state.dart | 21 - lib/l10n/intl_ca.arb | 28 +- lib/l10n/intl_cs.arb | 12 +- lib/l10n/intl_de.arb | 12 +- lib/l10n/intl_en.arb | 12 +- lib/l10n/intl_es.arb | 32 +- lib/l10n/intl_fr.arb | 12 +- lib/l10n/intl_pl.arb | 12 +- lib/l10n/intl_ru.arb | 12 +- lib/l10n/intl_tr.arb | 12 +- .../typed/top_level/logging_out_route.dart | 5 +- pubspec.yaml | 2 +- 40 files changed, 804 insertions(+), 609 deletions(-) create mode 100644 android/fastlane/metadata/android/de-DE/changelogs/54.txt create mode 100644 android/fastlane/metadata/android/en-US/changelogs/54.txt create mode 100644 lib/features/documents/view/widgets/date_and_document_type_widget.dart create mode 100644 lib/features/settings/view/widgets/app_logs_tile.dart create mode 100644 lib/features/settings/view/widgets/changelogs_tile.dart 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/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 f992ca3..22aa023 100644 --- a/lib/core/notifier/document_changed_notifier.dart +++ b/lib/core/notifier/document_changed_notifier.dart @@ -27,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/widgets/form_builder_fields/form_builder_localized_date_picker.dart b/lib/core/widgets/form_builder_fields/form_builder_localized_date_picker.dart index 2d25a92..eb1ab5f 100644 --- a/lib/core/widgets/form_builder_fields/form_builder_localized_date_picker.dart +++ b/lib/core/widgets/form_builder_fields/form_builder_localized_date_picker.dart @@ -53,6 +53,7 @@ class FormBuilderLocalizedDatePicker extends StatefulWidget { 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; @@ -67,6 +68,7 @@ class FormBuilderLocalizedDatePicker extends StatefulWidget { required this.labelText, this.prefixIcon, this.allowUnset = false, + this.focusNode, }); @override @@ -100,8 +102,11 @@ class _FormBuilderLocalizedDatePickerState 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(debugLabel: formatString), + node: focusNode, controller: TextEditingController(text: initialText), format: formatString, position: i, diff --git a/lib/features/app_drawer/view/app_drawer.dart b/lib/features/app_drawer/view/app_drawer.dart index ca4fdf2..28e3877 100644 --- a/lib/features/app_drawer/view/app_drawer.dart +++ b/lib/features/app_drawer/view/app_drawer.dart @@ -106,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), @@ -179,14 +171,6 @@ class AppDrawer extends StatelessWidget { .fade(duration: 1.seconds, begin: 1, end: 0.3); }, ), - ListTile( - dense: true, - leading: const Icon(Icons.subject), - title: Text(S.of(context)!.appLogs('')), - onTap: () { - AppLogsRoute().push(context); - }, - ), ListTile( dense: true, leading: const Icon(Icons.settings_outlined), diff --git a/lib/features/changelogs/view/changelog_dialog.dart b/lib/features/changelogs/view/changelog_dialog.dart index 7ebfa9e..bad8155 100644 --- a/lib/features/changelogs/view/changelog_dialog.dart +++ b/lib/features/changelogs/view/changelog_dialog.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_details/cubit/document_details_cubit.dart b/lib/features/document_details/cubit/document_details_cubit.dart index 4b38265..466d911 100644 --- a/lib/features/document_details/cubit/document_details_cubit.dart +++ b/lib/features/document_details/cubit/document_details_cubit.dart @@ -31,14 +31,13 @@ class DocumentDetailsCubit extends Cubit { this._notificationService, { required this.id, }) : super(const DocumentDetailsInitial()) { - _notifier.addListener(this, onUpdated: (document) { - if (state is DocumentDetailsLoaded) { - final currentState = state as DocumentDetailsLoaded; - if (document.id == currentState.document.id) { - replace(document); - } - } - }); + _notifier.addListener( + this, + onUpdated: (document) { + replace(document); + }, + ids: [id], + ); } Future initialize() async { diff --git a/lib/features/document_edit/cubit/document_edit_cubit.dart b/lib/features/document_edit/cubit/document_edit_cubit.dart index 850f41a..839bf98 100644 --- a/lib/features/document_edit/cubit/document_edit_cubit.dart +++ b/lib/features/document_edit/cubit/document_edit_cubit.dart @@ -22,11 +22,13 @@ class DocumentEditCubit extends Cubit { required DocumentModel document, }) : _initialDocument = document, super(DocumentEditState(document: document)) { - _notifier.addListener(this, onUpdated: (doc) { - if (doc.id == document.id) { + _notifier.addListener( + this, + onUpdated: (doc) { emit(state.copyWith(document: doc)); - } - }); + }, + ids: [document.id], + ); } Future updateDocument(DocumentModel document) async { diff --git a/lib/features/document_edit/view/document_edit_page.dart b/lib/features/document_edit/view/document_edit_page.dart index 2acf274..18a7e32 100644 --- a/lib/features/document_edit/view/document_edit_page.dart +++ b/lib/features/document_edit/view/document_edit_page.dart @@ -9,13 +9,13 @@ 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/extensions/flutter_extensions.dart'; import 'package:paperless_mobile/core/repository/label_repository.dart'; -import 'package:paperless_mobile/core/widgets/dialog_utils/dialog_cancel_button.dart'; import 'package:paperless_mobile/core/widgets/dialog_utils/pop_with_unsaved_changes.dart'; import 'package:paperless_mobile/core/widgets/form_builder_fields/form_builder_localized_date_picker.dart'; import 'package:paperless_mobile/core/workarounds/colored_chip.dart'; -import 'package:paperless_mobile/core/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'; @@ -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,6 +44,23 @@ 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; @@ -75,197 +93,228 @@ 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: 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(), - 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), + ), + ); + }, + ), + ], + ), + ), ), ); }, ); } + 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), + ], + ), + ), + ], + ), + ); + } + ( String? title, int? correspondent, 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 7e7fd99..addcfad 100644 --- a/lib/features/documents/view/pages/documents_page.dart +++ b/lib/features/documents/view/pages/documents_page.dart @@ -110,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) { @@ -429,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/items/document_detailed_item.dart b/lib/features/documents/view/widgets/items/document_detailed_item.dart index 127457d..0410e61 100644 --- a/lib/features/documents/view/widgets/items/document_detailed_item.dart +++ b/lib/features/documents/view/widgets/items/document_detailed_item.dart @@ -11,6 +11,7 @@ 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/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'; @@ -100,38 +101,28 @@ class DocumentDetailedItem extends DocumentItem { ], ), ), + 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, + style: Theme.of(context).textTheme.titleMedium, + maxLines: 2, + overflow: TextOverflow.ellipsis, + ).paddedLTRB(8, 8, 8, 4), Row( mainAxisAlignment: MainAxisAlignment.spaceBetween, crossAxisAlignment: CrossAxisAlignment.start, children: [ Expanded( - child: RichText( - maxLines: 1, - overflow: TextOverflow.ellipsis, - text: TextSpan( - style: Theme.of(context) - .textTheme - .bodySmall - ?.apply(color: Theme.of(context).hintColor), - text: DateFormat.yMMMMd( - Localizations.localeOf(context).toString()) - .format(document.created), - children: [ - if (paperlessUser.canViewDocumentTypes && - document.documentType != null) ...[ - const TextSpan(text: '\u30FB'), - TextSpan( - text: labels - .documentTypes[document.documentType]?.name, - recognizer: onDocumentTypeSelected != null - ? (TapGestureRecognizer() - ..onTap = () => onDocumentTypeSelected!( - document.documentType)) - : null, - ), - ], - ], - ), + child: DateAndDocumentTypeLabelWidget( + document: document, + onDocumentTypeSelected: onDocumentTypeSelected, ), ), if (document.archiveSerialNumber != null) @@ -143,30 +134,7 @@ class DocumentDetailedItem extends DocumentItem { ?.apply(color: Theme.of(context).hintColor), ), ], - ).paddedLTRB(8, 8, 8, 4), - Text( - 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: - labels.correspondents[document.correspondent], - ), - ], - ).paddedLTRB(8, 0, 8, 8), + ).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 b727f3a..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( - documentId: document.id, - 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 2767288..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, @@ -75,35 +78,11 @@ class DocumentListItem extends DocumentItem { ), ], ), - subtitle: IntrinsicWidth( - child: Padding( - padding: const EdgeInsets.symmetric(vertical: 4), - child: RichText( - maxLines: 1, - overflow: TextOverflow.ellipsis, - text: TextSpan( - text: - DateFormat.yMMMMd(Localizations.localeOf(context).toString()) - .format(document.created), - style: Theme.of(context) - .textTheme - .labelSmall - ?.apply(color: Colors.grey), - children: document.documentType != null - ? [ - const TextSpan(text: '\u30FB'), - TextSpan( - text: labels.documentTypes[document.documentType]?.name, - recognizer: onDocumentTypeSelected != null - ? (TapGestureRecognizer() - ..onTap = () => onDocumentTypeSelected!( - document.documentType)) - : null, - ), - ] - : null, - ), - ), + subtitle: Padding( + padding: const EdgeInsets.symmetric(vertical: 4), + child: DateAndDocumentTypeLabelWidget( + document: document, + onDocumentTypeSelected: onDocumentTypeSelected, ), ), isThreeLine: document.tags.isNotEmpty, 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/logging/view/app_logs_page.dart b/lib/features/logging/view/app_logs_page.dart index f8fef47..19e0d16 100644 --- a/lib/features/logging/view/app_logs_page.dart +++ b/lib/features/logging/view/app_logs_page.dart @@ -70,7 +70,9 @@ class _AppLogsPageState extends State { ), ), appBar: AppBar( - title: Text(S.of(context)!.appLogs(formattedDate)), + title: Text(S + .of(context)! + .appLogs(formattedDate)), //TODO: CHange to App-Logs in german actions: [ if (state is AppLogsStateLoaded) IconButton( diff --git a/lib/features/login/view/add_account_page.dart b/lib/features/login/view/add_account_page.dart index bf450be..07eab01 100644 --- a/lib/features/login/view/add_account_page.dart +++ b/lib/features/login/view/add_account_page.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/widgets/form_fields/user_credentials_form_field.dart b/lib/features/login/view/widgets/form_fields/user_credentials_form_field.dart index e96d7d6..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 @@ -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/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/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/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/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/l10n/intl_ca.arb b/lib/l10n/intl_ca.arb index 7428f7b..f639029 100644 --- a/lib/l10n/intl_ca.arb +++ b/lib/l10n/intl_ca.arb @@ -1001,13 +1001,23 @@ "@discardChangesWarning": { "description": "Warning message shown when the user tries to close a route without saving the changes." }, - "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}" + "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 e151c4d..19be896 100644 --- a/lib/l10n/intl_cs.arb +++ b/lib/l10n/intl_cs.arb @@ -1009,5 +1009,15 @@ "copyToClipboard": "Copy to clipboard", "couldNotLoadLogfileFrom": "Could not load logfile from {date}.", "loadingLogsFrom": "Loading logs from {date}...", - "clearLogs": "Clear 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 d902965..7b7345b 100644 --- a/lib/l10n/intl_de.arb +++ b/lib/l10n/intl_de.arb @@ -1009,5 +1009,15 @@ "copyToClipboard": "In Zwischenablage kopieren", "couldNotLoadLogfileFrom": "Logs vom {date} konnten nicht geladen werden.", "loadingLogsFrom": "Lade Logs vom {date}...", - "clearLogs": "Logs vom {date} leeren" + "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 2bfc980..3dcfffe 100644 --- a/lib/l10n/intl_en.arb +++ b/lib/l10n/intl_en.arb @@ -1009,5 +1009,15 @@ "copyToClipboard": "Copy to clipboard", "couldNotLoadLogfileFrom": "Could not load logfile from {date}.", "loadingLogsFrom": "Loading logs from {date}...", - "clearLogs": "Clear 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 61e2c55..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,21 +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", - "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}" + "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 c605519..c33ce6b 100644 --- a/lib/l10n/intl_fr.arb +++ b/lib/l10n/intl_fr.arb @@ -1009,5 +1009,15 @@ "copyToClipboard": "Copy to clipboard", "couldNotLoadLogfileFrom": "Could not load logfile from {date}.", "loadingLogsFrom": "Loading logs from {date}...", - "clearLogs": "Clear 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 b9589fa..5f1256a 100644 --- a/lib/l10n/intl_pl.arb +++ b/lib/l10n/intl_pl.arb @@ -1009,5 +1009,15 @@ "copyToClipboard": "Copy to clipboard", "couldNotLoadLogfileFrom": "Could not load logfile from {date}.", "loadingLogsFrom": "Loading logs from {date}...", - "clearLogs": "Clear 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 b6db6c0..a47d43b 100644 --- a/lib/l10n/intl_ru.arb +++ b/lib/l10n/intl_ru.arb @@ -1009,5 +1009,15 @@ "copyToClipboard": "Copy to clipboard", "couldNotLoadLogfileFrom": "Could not load logfile from {date}.", "loadingLogsFrom": "Loading logs from {date}...", - "clearLogs": "Clear 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 26935f8..2f69024 100644 --- a/lib/l10n/intl_tr.arb +++ b/lib/l10n/intl_tr.arb @@ -1009,5 +1009,15 @@ "copyToClipboard": "Copy to clipboard", "couldNotLoadLogfileFrom": "Could not load logfile from {date}.", "loadingLogsFrom": "Loading logs from {date}...", - "clearLogs": "Clear 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/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/pubspec.yaml b/pubspec.yaml index 04e7bc0..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"