diff --git a/lib/core/config/hive/hive_config.dart b/lib/core/config/hive/hive_config.dart index c0d8f7b..8952bc9 100644 --- a/lib/core/config/hive/hive_config.dart +++ b/lib/core/config/hive/hive_config.dart @@ -15,7 +15,6 @@ import 'package:paperless_mobile/features/settings/model/view_type.dart'; class HiveBoxes { HiveBoxes._(); static const globalSettings = 'globalSettings'; - static const authentication = 'authentication'; static const localUserCredentials = 'localUserCredentials'; static const localUserAccount = 'localUserAccount'; static const localUserAppState = 'localUserAppState'; diff --git a/lib/core/config/hive/hive_extensions.dart b/lib/core/config/hive/hive_extensions.dart index ee44085..83b8823 100644 --- a/lib/core/config/hive/hive_extensions.dart +++ b/lib/core/config/hive/hive_extensions.dart @@ -4,6 +4,11 @@ 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/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'; +import 'package:paperless_mobile/core/database/tables/local_user_settings.dart'; /// /// Opens an encrypted box, calls [callback] with the now opened box, awaits @@ -40,3 +45,16 @@ Future _getEncryptedBoxKey() async { final key = (await secureStorage.read(key: 'key'))!; return base64Decode(key); } + +extension HiveBoxAccessors on HiveInterface { + Box get settingsBox => + box(HiveBoxes.globalSettings); + Box get localUserAccountBox => + box(HiveBoxes.localUserAccount); + Box get localUserAppStateBox => + box(HiveBoxes.localUserAppState); + Box get localUserSettingsBox => + box(HiveBoxes.localUserSettings); + Box get globalSettingsBox => + box(HiveBoxes.globalSettings); +} diff --git a/lib/core/database/tables/global_settings.dart b/lib/core/database/tables/global_settings.dart index dda6961..fdcccbc 100644 --- a/lib/core/database/tables/global_settings.dart +++ b/lib/core/database/tables/global_settings.dart @@ -32,6 +32,9 @@ class GlobalSettings with HiveObjectMixin { @HiveField(7, defaultValue: false) bool enforceSinglePagePdfUpload; + @HiveField(8, defaultValue: false) + bool skipDocumentPreprarationOnUpload; + GlobalSettings({ required this.preferredLocaleSubtag, this.preferredThemeMode = ThemeMode.system, @@ -41,5 +44,6 @@ class GlobalSettings with HiveObjectMixin { this.defaultDownloadType = FileDownloadType.alwaysAsk, this.defaultShareType = FileDownloadType.alwaysAsk, this.enforceSinglePagePdfUpload = false, + this.skipDocumentPreprarationOnUpload = false, }); } diff --git a/lib/core/global/constants.dart b/lib/core/global/constants.dart index 652572f..04230a7 100644 --- a/lib/core/global/constants.dart +++ b/lib/core/global/constants.dart @@ -1 +1,8 @@ -const supportedFileExtensions = ['pdf', 'png', 'tiff', 'gif', 'jpg', 'jpeg']; +const supportedFileExtensions = [ + '.pdf', + '.png', + '.tiff', + '.gif', + '.jpg', + '.jpeg' +]; diff --git a/lib/core/service/file_service.dart b/lib/core/service/file_service.dart index 00145a8..61d7832 100644 --- a/lib/core/service/file_service.dart +++ b/lib/core/service/file_service.dart @@ -3,9 +3,12 @@ import 'dart:io'; import 'package:flutter/foundation.dart'; import 'package:paperless_api/paperless_api.dart'; import 'package:path_provider/path_provider.dart'; +import 'package:rxdart/rxdart.dart'; import 'package:uuid/uuid.dart'; class FileService { + const FileService._(); + static Future saveToFile( Uint8List bytes, String filename, @@ -19,16 +22,13 @@ class FileService { } static Future getDirectory(PaperlessDirectoryType type) { - switch (type) { - case PaperlessDirectoryType.documents: - return documentsDirectory; - case PaperlessDirectoryType.temporary: - return temporaryDirectory; - case PaperlessDirectoryType.scans: - return temporaryScansDirectory; - case PaperlessDirectoryType.download: - return downloadsDirectory; - } + return switch (type) { + PaperlessDirectoryType.documents => documentsDirectory, + PaperlessDirectoryType.temporary => temporaryDirectory, + PaperlessDirectoryType.scans => temporaryScansDirectory, + PaperlessDirectoryType.download => downloadsDirectory, + PaperlessDirectoryType.upload => uploadDirectory, + }; } static Future allocateTemporaryFile( @@ -50,8 +50,8 @@ class FileService { ))! .first; } else if (Platform.isIOS) { - final appDir = await getApplicationDocumentsDirectory(); - final dir = Directory('${appDir.path}/documents'); + final dir = await getApplicationDocumentsDirectory() + .then((dir) => Directory('${dir.path}/documents')); return dir.create(recursive: true); } else { throw UnsupportedError("Platform not supported."); @@ -77,17 +77,32 @@ class FileService { } } + 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() async { + static Future clearUserData({required String userId}) async { final scanDir = await temporaryScansDirectory; final tempDir = await temporaryDirectory; + final consumptionDir = await getConsumptionDirectory(userId: userId); await scanDir.delete(recursive: true); await tempDir.delete(recursive: true); + await consumptionDir.delete(recursive: true); } static Future clearDirectoryContent(PaperlessDirectoryType type) async { @@ -101,11 +116,20 @@ class FileService { dir.listSync().map((item) => item.delete(recursive: true)), ); } + + static Future> getAllFiles(Directory directory) { + return directory.list().whereType().toList(); + } + + static Future> getAllSubdirectories(Directory directory) { + return directory.list().whereType().toList(); + } } enum PaperlessDirectoryType { documents, temporary, scans, - download; + download, + upload; } diff --git a/lib/core/widgets/future_or_builder.dart b/lib/core/widgets/future_or_builder.dart new file mode 100644 index 0000000..4651f5d --- /dev/null +++ b/lib/core/widgets/future_or_builder.dart @@ -0,0 +1,35 @@ +import 'dart:async'; + +import 'package:flutter/material.dart'; + +class FutureOrBuilder extends StatelessWidget { + final FutureOr? futureOrValue; + + final T? initialData; + + final AsyncWidgetBuilder builder; + + const FutureOrBuilder({ + super.key, + FutureOr? future, + this.initialData, + required this.builder, + }) : futureOrValue = future; + + @override + Widget build(BuildContext context) { + final futureOrValue = this.futureOrValue; + if (futureOrValue is T) { + return builder( + context, + AsyncSnapshot.withData(ConnectionState.done, futureOrValue), + ); + } else { + return FutureBuilder( + future: futureOrValue, + initialData: initialData, + builder: builder, + ); + } + } +} diff --git a/lib/features/app_drawer/view/app_drawer.dart b/lib/features/app_drawer/view/app_drawer.dart index 11402a9..14d0a64 100644 --- a/lib/features/app_drawer/view/app_drawer.dart +++ b/lib/features/app_drawer/view/app_drawer.dart @@ -1,11 +1,19 @@ 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/widgets/paperless_logo.dart'; import 'package:paperless_mobile/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'; import 'package:paperless_mobile/generated/l10n/app_localizations.dart'; +import 'package:paperless_mobile/routes/typed/branches/documents_route.dart'; +import 'package:paperless_mobile/routes/typed/branches/upload_queue_route.dart'; import 'package:paperless_mobile/routes/typed/top_level/settings_route.dart'; +import 'package:provider/provider.dart'; import 'package:url_launcher/url_launcher_string.dart'; class AppDrawer extends StatelessWidget { @@ -16,6 +24,7 @@ class AppDrawer extends StatelessWidget { return SafeArea( child: Drawer( child: Column( + crossAxisAlignment: CrossAxisAlignment.start, children: [ Row( children: [ @@ -93,6 +102,29 @@ class AppDrawer extends StatelessWidget { ); }, ), + Consumer( + builder: (context, value, child) { + final files = value.pendingFiles; + final child = ListTile( + dense: true, + leading: const Icon(Icons.drive_folder_upload_outlined), + title: const Text("Upload Queue"), + onTap: () { + UploadQueueRoute().push(context); + }, + trailing: Text( + '${files.length}', + style: Theme.of(context).textTheme.bodyMedium, + ), + ); + if (files.isEmpty) { + return child; + } + return child + .animate(onPlay: (c) => c.repeat(reverse: true)) + .fade(duration: 1.seconds, begin: 1, end: 0.3); + }, + ), ListTile( dense: true, leading: const Icon(Icons.settings_outlined), @@ -101,12 +133,57 @@ class AppDrawer extends StatelessWidget { ), onTap: () => SettingsRoute().push(context), ), + const Divider(), + Text( + S.of(context)!.views, + textAlign: TextAlign.left, + style: Theme.of(context).textTheme.labelLarge, + ).padded(16), + _buildSavedViews(), ], ), ), ); } + Widget _buildSavedViews() { + return BlocBuilder( + builder: (context, state) { + return state.when( + initial: () => const SizedBox.shrink(), + loading: () => const Center(child: CircularProgressIndicator()), + loaded: (savedViews) { + final sidebarViews = savedViews.values + .where((element) => element.showInSidebar) + .toList(); + if (sidebarViews.isEmpty) { + return Text("Nothing to show here.").paddedOnly(left: 16); + } + return Expanded( + child: ListView.builder( + itemBuilder: (context, index) { + final view = sidebarViews[index]; + return ListTile( + title: Text(view.name), + trailing: Icon(Icons.arrow_forward), + onTap: () { + Scaffold.of(context).closeDrawer(); + context + .read() + .updateFilter(filter: view.toDocumentFilter()); + DocumentsRoute().go(context); + }, + ); + }, + itemCount: sidebarViews.length, + ), + ); + }, + error: () => Text(S.of(context)!.couldNotLoadSavedViews), + ); + }); + } + void _showAboutDialog(BuildContext context) { final theme = Theme.of(context); final colorScheme = theme.colorScheme; diff --git a/lib/features/document_scan/view/scanner_page.dart b/lib/features/document_scan/view/scanner_page.dart index 4ea62ca..0a5d788 100644 --- a/lib/features/document_scan/view/scanner_page.dart +++ b/lib/features/document_scan/view/scanner_page.dart @@ -265,7 +265,7 @@ class _ScannerPageState extends State // For paperless version older than 1.11.3, task id will always be null! context.read().reset(); context - .read() + .read() .listenToTaskChanges(uploadResult!.taskId!); } } 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 0503da4..4412b0c 100644 --- a/lib/features/document_upload/view/document_upload_preparation_page.dart +++ b/lib/features/document_upload/view/document_upload_preparation_page.dart @@ -1,3 +1,4 @@ +import 'dart:async'; import 'dart:typed_data'; import 'package:flutter/material.dart'; @@ -13,6 +14,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/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/features/document_upload/cubit/document_upload_cubit.dart'; import 'package:paperless_mobile/features/edit_label/view/impl/add_correspondent_page.dart'; @@ -20,6 +22,7 @@ import 'package:paperless_mobile/features/edit_label/view/impl/add_document_type 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/sharing/view/widgets/file_thumbnail.dart'; import 'package:paperless_mobile/generated/l10n/app_localizations.dart'; import 'package:paperless_mobile/helpers/message_helpers.dart'; @@ -33,7 +36,7 @@ class DocumentUploadResult { } class DocumentUploadPreparationPage extends StatefulWidget { - final Uint8List fileBytes; + final FutureOr fileBytes; final String? title; final String? filename; final String? fileExtension; @@ -68,9 +71,10 @@ class _DocumentUploadPreparationPageState void initState() { super.initState(); _syncTitleAndFilename = widget.filename == null && widget.title == null; - _titleColor = _computeAverageColor().computeLuminance() > 0.5 - ? Colors.black - : Colors.white; + _computeAverageColor().then((value) { + _titleColor = + value.computeLuminance() > 0.5 ? Colors.black : Colors.white; + }); initializeDateFormatting(); } @@ -104,9 +108,17 @@ class _DocumentUploadPreparationPageState pinned: true, expandedHeight: 150, flexibleSpace: FlexibleSpaceBar( - background: Image.memory( - widget.fileBytes, - fit: BoxFit.cover, + background: FutureOrBuilder( + future: widget.fileBytes, + builder: (context, snapshot) { + if (!snapshot.hasData) { + return const SizedBox.shrink(); + } + return FileThumbnail( + bytes: snapshot.data!, + fit: BoxFit.fitWidth, + ); + }, ), title: Text( S.of(context)!.prepareDocument, @@ -116,7 +128,7 @@ class _DocumentUploadPreparationPageState ), ), bottom: _isUploadLoading - ? const PreferredSize( + ? PreferredSize( child: LinearProgressIndicator(), preferredSize: Size.fromHeight(4.0), ) @@ -359,7 +371,7 @@ class _DocumentUploadPreparationPageState ?.whenOrNull(fromId: (id) => id); final asn = fv[DocumentModel.asnKey] as int?; final taskId = await cubit.upload( - widget.fileBytes, + await widget.fileBytes, filename: _padWithExtension( _formKey.currentState?.value[fkFileName], widget.fileExtension, @@ -404,8 +416,8 @@ class _DocumentUploadPreparationPageState return source.replaceAll(RegExp(r"[\W_]"), "_").toLowerCase(); } - Color _computeAverageColor() { - final bitmap = img.decodeImage(widget.fileBytes); + Future _computeAverageColor() async { + final bitmap = img.decodeImage(await widget.fileBytes); if (bitmap == null) { return Colors.black; } diff --git a/lib/features/documents/view/pages/documents_page.dart b/lib/features/documents/view/pages/documents_page.dart index d506060..4aca245 100644 --- a/lib/features/documents/view/pages/documents_page.dart +++ b/lib/features/documents/view/pages/documents_page.dart @@ -59,18 +59,48 @@ class _DocumentsPageState extends State { @override void initState() { super.initState(); + context.read().addListener(_onTasksChanged); WidgetsBinding.instance.addPostFrameCallback((_) { _nestedScrollViewKey.currentState!.innerController .addListener(_scrollExtentChangedListener); }); } + void _onTasksChanged() { + final notifier = context.read(); + final tasks = notifier.value; + final finishedTasks = tasks.values.where((element) => element.isSuccess); + if (finishedTasks.isNotEmpty) { + showSnackBar( + context, + S.of(context)!.newDocumentAvailable, + action: SnackBarActionConfig( + label: S.of(context)!.reload, + onPressed: () { + // finishedTasks.forEach((task) { + // notifier.acknowledgeTasks([finishedTasks]); + // }); + context.read().reload(); + }, + ), + duration: const Duration(seconds: 10), + ); + } + } + Future _reloadData() async { + final user = context.read().paperlessUser; try { await Future.wait([ context.read().reload(), - context.read().reload(), - context.read().reload(), + if (user.canViewSavedViews) context.read().reload(), + if (user.canViewTags) context.read().reloadTags(), + if (user.canViewCorrespondents) + context.read().reloadCorrespondents(), + if (user.canViewDocumentTypes) + context.read().reloadDocumentTypes(), + if (user.canViewStoragePaths) + context.read().reloadStoragePaths(), ]); } catch (error, stackTrace) { showGenericError(context, error, stackTrace); @@ -96,196 +126,174 @@ class _DocumentsPageState extends State { void dispose() { _nestedScrollViewKey.currentState?.innerController .removeListener(_scrollExtentChangedListener); + context.read().removeListener(_onTasksChanged); + super.dispose(); } @override Widget build(BuildContext context) { - return BlocListener( + return BlocConsumer( listenWhen: (previous, current) => - !previous.isSuccess && current.isSuccess, + previous != ConnectivityState.connected && + current == ConnectivityState.connected, listener: (context, state) { - showSnackBar( - context, - S.of(context)!.newDocumentAvailable, - action: SnackBarActionConfig( - label: S.of(context)!.reload, - onPressed: () { - context.read().acknowledgeCurrentTask(); - context.read().reload(); - }, - ), - duration: const Duration(seconds: 10), - ); + _reloadData(); }, - child: BlocConsumer( - listenWhen: (previous, current) => - previous != ConnectivityState.connected && - current == ConnectivityState.connected, - listener: (context, state) { - _reloadData(); - }, - builder: (context, connectivityState) { - return SafeArea( - top: true, - child: Scaffold( - drawer: const AppDrawer(), - floatingActionButton: BlocBuilder( - builder: (context, state) { - final show = state.selection.isEmpty; - final canReset = state.filter.appliedFiltersCount > 0; - if (show) { - return Column( - mainAxisAlignment: MainAxisAlignment.end, - children: [ - DeferredPointerHandler( - child: Stack( - clipBehavior: Clip.none, - children: [ - FloatingActionButton.extended( - extendedPadding: _showExtendedFab - ? null - : const EdgeInsets.symmetric( - horizontal: 16), - heroTag: "fab_documents_page_filter", - label: AnimatedSwitcher( - duration: const Duration(milliseconds: 150), - transitionBuilder: (child, animation) { - return FadeTransition( - opacity: animation, - child: SizeTransition( - sizeFactor: animation, - axis: Axis.horizontal, - child: child, - ), - ); - }, - child: _showExtendedFab - ? Row( - children: [ - const Icon( - Icons.filter_alt_outlined, - ), - const SizedBox(width: 8), - Text( - S.of(context)!.filterDocuments, - ), - ], - ) - : const Icon(Icons.filter_alt_outlined), - ), - onPressed: _openDocumentFilter, + builder: (context, connectivityState) { + return SafeArea( + top: true, + child: Scaffold( + drawer: const AppDrawer(), + floatingActionButton: BlocBuilder( + builder: (context, state) { + final show = state.selection.isEmpty; + final canReset = state.filter.appliedFiltersCount > 0; + if (show) { + return Column( + mainAxisAlignment: MainAxisAlignment.end, + children: [ + DeferredPointerHandler( + child: Stack( + clipBehavior: Clip.none, + children: [ + FloatingActionButton.extended( + extendedPadding: _showExtendedFab + ? null + : const EdgeInsets.symmetric(horizontal: 16), + heroTag: "fab_documents_page_filter", + label: AnimatedSwitcher( + duration: const Duration(milliseconds: 150), + transitionBuilder: (child, animation) { + return FadeTransition( + opacity: animation, + child: SizeTransition( + sizeFactor: animation, + axis: Axis.horizontal, + child: child, + ), + ); + }, + child: _showExtendedFab + ? Row( + children: [ + const Icon( + Icons.filter_alt_outlined, + ), + const SizedBox(width: 8), + Text( + S.of(context)!.filterDocuments, + ), + ], + ) + : const Icon(Icons.filter_alt_outlined), ), - if (canReset) - Positioned( - top: -20, - right: -8, - child: DeferPointer( - paintOnTop: true, - child: Material( - color: - Theme.of(context).colorScheme.error, + onPressed: _openDocumentFilter, + ), + if (canReset) + Positioned( + top: -20, + right: -8, + child: DeferPointer( + paintOnTop: true, + child: Material( + color: Theme.of(context).colorScheme.error, + borderRadius: BorderRadius.circular(8), + child: InkWell( borderRadius: BorderRadius.circular(8), - child: InkWell( - borderRadius: BorderRadius.circular(8), - onTap: () { - HapticFeedback.mediumImpact(); - _onResetFilter(); - }, - child: Row( - mainAxisSize: MainAxisSize.min, - mainAxisAlignment: - MainAxisAlignment.spaceBetween, - children: [ - if (_showExtendedFab) - Text( - "Reset (${state.filter.appliedFiltersCount})", - style: Theme.of(context) - .textTheme - .labelLarge - ?.copyWith( - color: Theme.of(context) - .colorScheme - .onError, - ), - ).padded() - else - Icon( - Icons.replay, - color: Theme.of(context) - .colorScheme - .onError, - ).padded(4), - ], - ), + onTap: () { + HapticFeedback.mediumImpact(); + _onResetFilter(); + }, + child: Row( + mainAxisSize: MainAxisSize.min, + mainAxisAlignment: + MainAxisAlignment.spaceBetween, + children: [ + if (_showExtendedFab) + Text( + "Reset (${state.filter.appliedFiltersCount})", + style: Theme.of(context) + .textTheme + .labelLarge + ?.copyWith( + color: Theme.of(context) + .colorScheme + .onError, + ), + ).padded() + else + Icon( + Icons.replay, + color: Theme.of(context) + .colorScheme + .onError, + ).padded(4), + ], ), ), ), ), - ], - ), - ), - ], - ); - } else { - return const SizedBox.shrink(); - } - }, - ), - resizeToAvoidBottomInset: true, - body: WillPopScope( - onWillPop: () async { - if (context - .read() - .state - .selection - .isNotEmpty) { - context.read().resetSelection(); - return false; - } - return true; - }, - child: NestedScrollView( - key: _nestedScrollViewKey, - floatHeaderSlivers: true, - headerSliverBuilder: (context, innerBoxIsScrolled) => [ - SliverOverlapAbsorber( - handle: searchBarHandle, - sliver: BlocBuilder( - builder: (context, state) { - if (state.selection.isEmpty) { - return SliverSearchBar( - floating: true, - titleText: S.of(context)!.documents, - ); - } else { - return DocumentSelectionSliverAppBar( - state: state, - ); - } - }, - ), - ), - SliverOverlapAbsorber( - handle: savedViewsHandle, - sliver: SliverPinnedHeader( - child: Material( - child: _buildViewActions(), - elevation: 2, + ), + ], ), ), + ], + ); + } else { + return const SizedBox.shrink(); + } + }, + ), + resizeToAvoidBottomInset: true, + body: WillPopScope( + onWillPop: () async { + if (context.read().state.selection.isNotEmpty) { + context.read().resetSelection(); + return false; + } + return true; + }, + child: NestedScrollView( + key: _nestedScrollViewKey, + floatHeaderSlivers: true, + headerSliverBuilder: (context, innerBoxIsScrolled) => [ + SliverOverlapAbsorber( + handle: searchBarHandle, + sliver: BlocBuilder( + builder: (context, state) { + if (state.selection.isEmpty) { + return SliverSearchBar( + floating: true, + titleText: S.of(context)!.documents, + ); + } else { + return DocumentSelectionSliverAppBar( + state: state, + ); + } + }, ), - ], - body: _buildDocumentsTab( - connectivityState, - context, ), + SliverOverlapAbsorber( + handle: savedViewsHandle, + sliver: SliverPinnedHeader( + child: Material( + child: _buildViewActions(), + elevation: 2, + ), + ), + ), + ], + body: _buildDocumentsTab( + connectivityState, + context, ), ), ), - ); - }, - ), + ), + ); + }, ); } 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 0480d17..56ee187 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 @@ -126,7 +126,7 @@ class _SavedViewsWidgetState extends State .maybeMap( loaded: (value) { if (value.savedViews.isEmpty) { - return Text(S.of(context)!.noItemsFound) + return Text(S.of(context)!.youDidNotSaveAnyViewsYet) .paddedOnly(left: 16); } diff --git a/lib/features/home/view/home_shell_widget.dart b/lib/features/home/view/home_shell_widget.dart index e73e302..07709d4 100644 --- a/lib/features/home/view/home_shell_widget.dart +++ b/lib/features/home/view/home_shell_widget.dart @@ -47,8 +47,8 @@ class HomeShellWidget extends StatelessWidget { builder: (context, settings) { final currentUserId = settings.loggedInUserId; if (currentUserId == null) { - // This is the case when the current user logs out of the app. - return SizedBox.shrink(); + // This is currently the case (only for a few ms) when the current user logs out of the app. + return const SizedBox.shrink(); } final apiVersion = ApiVersion(paperlessApiVersion); return ValueListenableBuilder( @@ -57,8 +57,6 @@ class HomeShellWidget extends StatelessWidget { .listenable(keys: [currentUserId]), builder: (context, box, _) { final currentLocalUser = box.get(currentUserId)!; - print(currentLocalUser.paperlessUser.canViewDocuments); - print(currentLocalUser.paperlessUser.canViewTags); return MultiProvider( key: ValueKey(currentUserId), providers: [ @@ -195,8 +193,8 @@ class HomeShellWidget extends StatelessWidget { context.read(), ), ), - Provider( - create: (context) => TaskStatusCubit( + ChangeNotifierProvider( + create: (context) => PendingTasksNotifier( context.read(), ), ), diff --git a/lib/features/login/cubit/authentication_cubit.dart b/lib/features/login/cubit/authentication_cubit.dart index cc695f9..177ca7d 100644 --- a/lib/features/login/cubit/authentication_cubit.dart +++ b/lib/features/login/cubit/authentication_cubit.dart @@ -17,6 +17,7 @@ import 'package:paperless_mobile/core/factory/paperless_api_factory.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'; +import 'package:paperless_mobile/core/service/file_service.dart'; import 'package:paperless_mobile/features/login/model/client_certificate.dart'; import 'package:paperless_mobile/features/login/model/login_form_credentials.dart'; import 'package:paperless_mobile/features/login/services/authentication_service.dart'; @@ -159,7 +160,7 @@ class AuthenticationCubit extends Cubit { Hive.box(HiveBoxes.localUserAccount); final userAppStateBox = Hive.box(HiveBoxes.localUserAppState); - + await FileService.clearUserData(userId: userId); await userAccountBox.delete(userId); await userAppStateBox.delete(userId); await withEncryptedBox( diff --git a/lib/features/notifications/services/local_notification_service.dart b/lib/features/notifications/services/local_notification_service.dart index e651bb9..f4e7f85 100644 --- a/lib/features/notifications/services/local_notification_service.dart +++ b/lib/features/notifications/services/local_notification_service.dart @@ -133,7 +133,6 @@ class LocalNotificationService { ); } - //TODO: INTL Future notifyTaskChanged(Task task) { log("[LocalNotificationService] notifyTaskChanged: ${task.toString()}"); @@ -158,7 +157,7 @@ class LocalNotificationService { break; case TaskStatus.failure: title = "Failed to process document"; - body = "Document ${task.taskFileName} was rejected by the server."; + body = task.result ?? 'Rejected by the server.'; timestampMillis = task.dateCreated.millisecondsSinceEpoch; break; case TaskStatus.success: 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 cffd03d..2bd8fcd 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,10 +1,8 @@ import 'package:bloc/bloc.dart'; -import 'package:freezed_annotation/freezed_annotation.dart'; import 'package:paperless_api/paperless_api.dart'; import 'package:paperless_mobile/core/service/connectivity_status_service.dart'; part 'saved_view_preview_state.dart'; -part 'saved_view_preview_cubit.freezed.dart'; class SavedViewPreviewCubit extends Cubit { final PaperlessDocumentsApi _api; 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 2ebb459..ad1ff93 100644 --- a/lib/features/saved_view_details/view/saved_view_preview.dart +++ b/lib/features/saved_view_details/view/saved_view_preview.dart @@ -1,3 +1,5 @@ +import 'dart:ui'; + import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:paperless_api/paperless_api.dart'; diff --git a/lib/features/settings/view/settings_page.dart b/lib/features/settings/view/settings_page.dart index a0d5ce4..5be033c 100644 --- a/lib/features/settings/view/settings_page.dart +++ b/lib/features/settings/view/settings_page.dart @@ -7,6 +7,7 @@ import 'package:paperless_mobile/features/settings/view/widgets/default_download import 'package:paperless_mobile/features/settings/view/widgets/default_share_file_type_setting.dart'; import 'package:paperless_mobile/features/settings/view/widgets/enforce_pdf_upload_setting.dart'; import 'package:paperless_mobile/features/settings/view/widgets/language_selection_setting.dart'; +import 'package:paperless_mobile/features/settings/view/widgets/skip_document_prepraration_on_share_setting.dart'; import 'package:paperless_mobile/features/settings/view/widgets/theme_mode_setting.dart'; import 'package:paperless_mobile/features/settings/view/widgets/user_settings_builder.dart'; import 'package:paperless_mobile/generated/l10n/app_localizations.dart'; @@ -33,6 +34,7 @@ class SettingsPage extends StatelessWidget { const DefaultDownloadFileTypeSetting(), const DefaultShareFileTypeSetting(), const EnforcePdfUploadSetting(), + const SkipDocumentPreprationOnShareSetting(), _buildSectionHeader(context, S.of(context)!.storage), const ClearCacheSetting(), ], diff --git a/lib/features/settings/view/widgets/color_scheme_option_setting.dart b/lib/features/settings/view/widgets/color_scheme_option_setting.dart index 4bb2814..5a6f227 100644 --- a/lib/features/settings/view/widgets/color_scheme_option_setting.dart +++ b/lib/features/settings/view/widgets/color_scheme_option_setting.dart @@ -1,7 +1,6 @@ import 'dart:io'; import 'package:flutter/material.dart'; -import 'package:flutter/services.dart'; import 'package:paperless_mobile/constants.dart'; import 'package:paperless_mobile/core/translation/color_scheme_option_localization_mapper.dart'; import 'package:paperless_mobile/core/widgets/hint_card.dart'; @@ -9,7 +8,6 @@ import 'package:paperless_mobile/features/settings/model/color_scheme_option.dar import 'package:paperless_mobile/features/settings/view/widgets/global_settings_builder.dart'; import 'package:paperless_mobile/features/settings/view/widgets/radio_settings_dialog.dart'; import 'package:paperless_mobile/generated/l10n/app_localizations.dart'; -import 'package:paperless_mobile/theme.dart'; class ColorSchemeOptionSetting extends StatelessWidget { const ColorSchemeOptionSetting({super.key}); diff --git a/lib/features/settings/view/widgets/skip_document_prepraration_on_share_setting.dart b/lib/features/settings/view/widgets/skip_document_prepraration_on_share_setting.dart new file mode 100644 index 0000000..0d035c6 --- /dev/null +++ b/lib/features/settings/view/widgets/skip_document_prepraration_on_share_setting.dart @@ -0,0 +1,24 @@ +import 'package:flutter/material.dart'; +import 'package:paperless_mobile/features/settings/view/widgets/global_settings_builder.dart'; + +class SkipDocumentPreprationOnShareSetting extends StatelessWidget { + const SkipDocumentPreprationOnShareSetting({super.key}); + + @override + Widget build(BuildContext context) { + return GlobalSettingsBuilder( + builder: (context, settings) { + return SwitchListTile( + title: Text("Direct share"), + subtitle: + Text("Always directly upload when sharing files with the app."), + value: settings.skipDocumentPreprarationOnUpload, + onChanged: (value) { + settings.skipDocumentPreprarationOnUpload = value; + settings.save(); + }, + ); + }, + ); + } +} diff --git a/lib/features/sharing/cubit/receive_share_cubit.dart b/lib/features/sharing/cubit/receive_share_cubit.dart new file mode 100644 index 0000000..a6322e6 --- /dev/null +++ b/lib/features/sharing/cubit/receive_share_cubit.dart @@ -0,0 +1,72 @@ +import 'dart:io'; + +import 'package:bloc/bloc.dart'; +import 'package:flutter/foundation.dart'; +import 'package:paperless_mobile/core/service/file_service.dart'; +import 'package:path/path.dart' as p; +import 'package:provider/provider.dart'; + +part 'receive_share_state.dart'; + +class ConsumptionChangeNotifier extends ChangeNotifier { + List pendingFiles = []; + + ConsumptionChangeNotifier(); + + Future loadFromConsumptionDirectory({required String userId}) async { + pendingFiles = await _getCurrentFiles(userId); + notifyListeners(); + } + + /// Creates a local copy of all shared files and reloads all files + /// from the user's consumption directory. + Future addFiles({ + required List files, + required String userId, + }) async { + if (files.isEmpty) { + return; + } + final consumptionDirectory = + await FileService.getConsumptionDirectory(userId: userId); + for (final file in files) { + File localFile; + if (file.path.startsWith(consumptionDirectory.path)) { + localFile = file; + } else { + final fileName = p.basename(file.path); + localFile = File(p.join(consumptionDirectory.path, fileName)); + await file.copy(localFile.path); + } + } + return loadFromConsumptionDirectory(userId: userId); + } + + /// Marks a file as processed by removing it from the queue and deleting the local copy of the file. + Future discardFile( + File file, { + required String userId, + }) async { + final consumptionDirectory = + await FileService.getConsumptionDirectory(userId: userId); + if (file.path.startsWith(consumptionDirectory.path)) { + await file.delete(); + } + return loadFromConsumptionDirectory(userId: userId); + } + + /// Returns the next file to process of null if no file exists. + Future getNextFile({required String userId}) async { + final files = await _getCurrentFiles(userId); + if (files.isEmpty) { + return null; + } + return files.first; + } + + Future> _getCurrentFiles(String userId) async { + final directory = await FileService.getConsumptionDirectory(userId: userId); + final files = await FileService.getAllFiles(directory); + return files; + } +} diff --git a/lib/features/sharing/cubit/receive_share_state.dart b/lib/features/sharing/cubit/receive_share_state.dart new file mode 100644 index 0000000..c17ce2b --- /dev/null +++ b/lib/features/sharing/cubit/receive_share_state.dart @@ -0,0 +1,32 @@ +part of 'receive_share_cubit.dart'; + +sealed class ReceiveShareState { + final List files; + + const ReceiveShareState({this.files = const []}); +} + +class ReceiveShareStateInitial extends ReceiveShareState { + const ReceiveShareStateInitial(); +} + +class ReceiveShareStateLoading extends ReceiveShareState { + const ReceiveShareStateLoading(); +} + +class ReceiveShareStateLoaded extends ReceiveShareState { + const ReceiveShareStateLoaded({super.files}); + + ReceiveShareStateLoaded copyWith({ + List? files, + }) { + return ReceiveShareStateLoaded( + files: files ?? this.files, + ); + } +} + +class ReceiveShareStateError extends ReceiveShareState { + final String message; + const ReceiveShareStateError(this.message); +} diff --git a/lib/features/sharing/logic/upload_queue_processor.dart b/lib/features/sharing/logic/upload_queue_processor.dart new file mode 100644 index 0000000..89b0638 --- /dev/null +++ b/lib/features/sharing/logic/upload_queue_processor.dart @@ -0,0 +1,73 @@ +import 'dart:io'; + +import 'package:collection/collection.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter/widgets.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:fluttertoast/fluttertoast.dart'; +import 'package:paperless_api/paperless_api.dart'; +import 'package:paperless_mobile/core/database/tables/local_user_account.dart'; +import 'package:paperless_mobile/core/global/constants.dart'; +import 'package:paperless_mobile/core/translation/error_code_localization_mapper.dart'; +import 'package:paperless_mobile/features/document_upload/view/document_upload_preparation_page.dart'; +import 'package:paperless_mobile/features/sharing/model/share_intent_queue.dart'; +import 'package:paperless_mobile/features/sharing/view/dialog/discard_shared_file_dialog.dart'; +import 'package:paperless_mobile/generated/l10n/app_localizations.dart'; +import 'package:paperless_mobile/routes/typed/branches/scanner_route.dart'; +import 'package:receive_sharing_intent/receive_sharing_intent.dart'; +import 'package:path/path.dart' as p; + +class UploadQueueProcessor { + final ShareIntentQueue queue; + + UploadQueueProcessor({required this.queue}); + + bool _isFileTypeSupported(File file) { + final isSupported = + supportedFileExtensions.contains(p.extension(file.path)); + return isSupported; + } + + void processIncomingFiles( + BuildContext context, { + required List sharedFiles, + }) async { + if (sharedFiles.isEmpty) { + return; + } + Iterable files = sharedFiles.map((file) => File(file.path)); + if (Platform.isIOS) { + files = files + .map((file) => File(file.path.replaceAll('file://', ''))) + .toList(); + } + final supportedFiles = files.where(_isFileTypeSupported); + final unsupportedFiles = files.whereNot(_isFileTypeSupported); + debugPrint( + "Received ${files.length} files, out of which ${supportedFiles.length} are supported.}"); + if (supportedFiles.isEmpty) { + Fluttertoast.showToast( + msg: translateError( + context, + ErrorCode.unsupportedFileFormat, + ), + ); + if (Platform.isAndroid) { + // As stated in the docs, SystemNavigator.pop() is ignored on IOS to comply with HCI guidelines. + await SystemNavigator.pop(); + } + return; + } + if (unsupportedFiles.isNotEmpty) { + //TODO: INTL + Fluttertoast.showToast( + msg: + "${unsupportedFiles.length}/${files.length} files could not be processed."); + } + await ShareIntentQueue.instance.addAll( + supportedFiles, + userId: context.read().id, + ); + } +} diff --git a/lib/features/sharing/model/share_intent_queue.dart b/lib/features/sharing/model/share_intent_queue.dart new file mode 100644 index 0000000..6bd29a2 --- /dev/null +++ b/lib/features/sharing/model/share_intent_queue.dart @@ -0,0 +1,105 @@ +import 'dart:collection'; +import 'dart:io'; + +import 'package:flutter/widgets.dart'; +import 'package:hive/hive.dart'; +import 'package:paperless_mobile/core/config/hive/hive_extensions.dart'; +import 'package:paperless_mobile/core/service/file_service.dart'; +import 'package:receive_sharing_intent/receive_sharing_intent.dart'; +import 'package:path/path.dart' as p; + +class ShareIntentQueue extends ChangeNotifier { + final Map> _queues = {}; + + ShareIntentQueue._(); + + static final instance = ShareIntentQueue._(); + + Future initialize() async { + final users = Hive.localUserAccountBox.values; + for (final user in users) { + final userId = user.id; + debugPrint("Locating remaining files to be uploaded for $userId..."); + final consumptionDir = + await FileService.getConsumptionDirectory(userId: userId); + final files = await FileService.getAllFiles(consumptionDir); + debugPrint( + "Found ${files.length} files to be uploaded for $userId. Adding to queue..."); + getQueue(userId).addAll(files); + } + } + + void add( + File file, { + required String userId, + }) => + addAll([file], userId: userId); + + Future addAll( + Iterable files, { + required String userId, + }) async { + if (files.isEmpty) { + return; + } + final consumptionDirectory = + await FileService.getConsumptionDirectory(userId: userId); + final copiedFiles = await Future.wait([ + for (var file in files) + file.copy('${consumptionDirectory.path}/${p.basename(file.path)}') + ]); + + debugPrint( + "Adding received files to queue: ${files.map((e) => e.path).join(",")}", + ); + getQueue(userId).addAll(copiedFiles); + notifyListeners(); + } + + /// Removes and returns the first item in the requested user's queue if it exists. + File? pop(String userId) { + if (hasUnhandledFiles(userId: userId)) { + final file = getQueue(userId).removeFirst(); + notifyListeners(); + return file; + // Don't notify listeners, only when new item is added. + } + return null; + } + + Future onConsumed(File file) { + debugPrint( + "File ${file.path} successfully consumed. Delelting local copy."); + return file.delete(); + } + + Future discard(File file) { + debugPrint("Discarding file ${file.path}."); + return file.delete(); + } + + /// Returns whether the queue of the requested user contains files waiting for processing. + bool hasUnhandledFiles({ + required String userId, + }) => + getQueue(userId).isNotEmpty; + + int unhandledFileCount({ + required String userId, + }) => + getQueue(userId).length; + + Queue getQueue(String userId) { + if (!_queues.containsKey(userId)) { + _queues[userId] = Queue(); + } + return _queues[userId]!; + } +} + +class UserAwareShareMediaFile { + final String userId; + final SharedMediaFile sharedFile; + + UserAwareShareMediaFile(this.userId, this.sharedFile); +} diff --git a/lib/features/sharing/share_intent_queue.dart b/lib/features/sharing/share_intent_queue.dart deleted file mode 100644 index e551283..0000000 --- a/lib/features/sharing/share_intent_queue.dart +++ /dev/null @@ -1,56 +0,0 @@ -import 'dart:collection'; - -import 'package:flutter/widgets.dart'; -import 'package:receive_sharing_intent/receive_sharing_intent.dart'; - -class ShareIntentQueue extends ChangeNotifier { - final Map> _queues = {}; - - ShareIntentQueue._(); - - static final instance = ShareIntentQueue._(); - - void add( - SharedMediaFile file, { - required String userId, - }) { - debugPrint("Adding received file to queue: ${file.path}"); - _getQueue(userId).add(file); - notifyListeners(); - } - - void addAll( - Iterable files, { - required String userId, - }) { - debugPrint( - "Adding received files to queue: ${files.map((e) => e.path).join(",")}"); - _getQueue(userId).addAll(files); - notifyListeners(); - } - - SharedMediaFile? pop(String userId) { - if (userHasUnhandlesFiles(userId)) { - return _getQueue(userId).removeFirst(); - // Don't notify listeners, only when new item is added. - } else { - return null; - } - } - - Queue _getQueue(String userId) { - if (!_queues.containsKey(userId)) { - _queues[userId] = Queue(); - } - return _queues[userId]!; - } - - bool userHasUnhandlesFiles(String userId) => _getQueue(userId).isNotEmpty; -} - -class UserAwareShareMediaFile { - final String userId; - final SharedMediaFile sharedFile; - - UserAwareShareMediaFile(this.userId, this.sharedFile); -} diff --git a/lib/features/sharing/view/consumption_queue_view.dart b/lib/features/sharing/view/consumption_queue_view.dart new file mode 100644 index 0000000..c33527d --- /dev/null +++ b/lib/features/sharing/view/consumption_queue_view.dart @@ -0,0 +1,110 @@ +import 'dart:io'; + +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:intl/intl.dart'; +import 'package:paperless_mobile/core/database/tables/local_user_account.dart'; +import 'package:paperless_mobile/extensions/flutter_extensions.dart'; +import 'package:paperless_mobile/features/sharing/cubit/receive_share_cubit.dart'; +import 'package:paperless_mobile/features/sharing/view/widgets/file_thumbnail.dart'; +import 'package:paperless_mobile/features/sharing/view/widgets/upload_queue_shell.dart'; +import 'package:paperless_mobile/generated/l10n/app_localizations.dart'; +import 'package:path/path.dart' as p; +import 'package:provider/provider.dart'; + +class ConsumptionQueueView extends StatelessWidget { + const ConsumptionQueueView({super.key}); + + @override + Widget build(BuildContext context) { + final currentUser = context.watch(); + return Scaffold( + appBar: AppBar( + title: Text("Upload Queue"), //TODO: INTL + ), + body: Consumer( + builder: (context, value, child) { + if (value.pendingFiles.isEmpty) { + return Center( + child: Text("No pending files."), + ); + } + return ListView.builder( + itemBuilder: (context, index) { + final file = value.pendingFiles.elementAt(index); + final filename = p.basename(file.path); + return ListTile( + title: Text(filename), + leading: Padding( + padding: const EdgeInsets.all(4), + child: ClipRRect( + borderRadius: BorderRadius.circular(8), + child: FileThumbnail( + file: file, + fit: BoxFit.cover, + width: 75, + ), + ), + ), + trailing: IconButton( + icon: Icon(Icons.delete), + onPressed: () { + context + .read() + .discardFile(file, userId: currentUser.id); + }, + ), + ); + return Row( + mainAxisSize: MainAxisSize.max, + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + Expanded( + child: Column( + children: [ + Text(filename, maxLines: 1), + SizedBox( + height: 56, + child: ListView( + scrollDirection: Axis.horizontal, + children: [ + ActionChip( + label: Text(S.of(context)!.upload), + avatar: Icon(Icons.file_upload_outlined), + onPressed: () { + consumeLocalFile( + context, + file: file, + userId: currentUser.id, + ); + }, + ), + SizedBox(width: 8), + ActionChip( + label: Text(S.of(context)!.discard), + avatar: Icon(Icons.delete), + onPressed: () { + context + .read() + .discardFile( + file, + userId: currentUser.id, + ); + }, + ), + ], + ), + ), + ], + ).padded(), + ), + ], + ).padded(); + }, + itemCount: value.pendingFiles.length, + ); + }, + ), + ); + } +} diff --git a/lib/features/sharing/view/dialog/discard_shared_file_dialog.dart b/lib/features/sharing/view/dialog/discard_shared_file_dialog.dart new file mode 100644 index 0000000..311172e --- /dev/null +++ b/lib/features/sharing/view/dialog/discard_shared_file_dialog.dart @@ -0,0 +1,51 @@ +import 'dart:async'; +import 'dart:typed_data'; + +import 'package:flutter/material.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/future_or_builder.dart'; +import 'package:paperless_mobile/generated/l10n/app_localizations.dart'; +import 'package:transparent_image/transparent_image.dart'; + +class DiscardSharedFileDialog extends StatelessWidget { + final FutureOr bytes; + const DiscardSharedFileDialog({ + super.key, + required this.bytes, + }); + + @override + Widget build(BuildContext context) { + return AlertDialog( + icon: FutureOrBuilder( + future: bytes, + builder: (context, snapshot) { + if (!snapshot.hasData) { + return const CircularProgressIndicator(); + } + return LimitedBox( + maxHeight: 200, + maxWidth: 200, + child: FadeInImage( + fit: BoxFit.contain, + placeholder: MemoryImage(kTransparentImage), + image: MemoryImage(snapshot.data!), + ), + ); + }, + ), + title: Text(S.of(context)!.discardFile), + content: Text( + "The shared file was not yet processed. Do you want to discrad the file?", //TODO: INTL + ), + actions: [ + DialogCancelButton(), + DialogConfirmButton( + label: S.of(context)!.discard, + style: DialogConfirmButtonStyle.danger, + ), + ], + ); + } +} diff --git a/lib/features/sharing/view/widgets/file_thumbnail.dart b/lib/features/sharing/view/widgets/file_thumbnail.dart new file mode 100644 index 0000000..f4e8c91 --- /dev/null +++ b/lib/features/sharing/view/widgets/file_thumbnail.dart @@ -0,0 +1,102 @@ +import 'dart:io'; + +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:mime/mime.dart' as mime; +import 'package:printing/printing.dart'; +import 'package:transparent_image/transparent_image.dart'; + +class FileThumbnail extends StatefulWidget { + final File? file; + final Uint8List? bytes; + + final BoxFit? fit; + final double? width; + final double? height; + const FileThumbnail({ + super.key, + this.file, + this.bytes, + this.fit, + this.width, + this.height, + }) : assert((bytes != null) != (file != null)); + + @override + State createState() => _FileThumbnailState(); +} + +class _FileThumbnailState extends State { + late String? mimeType; + + @override + void initState() { + super.initState(); + mimeType = widget.file != null + ? mime.lookupMimeType(widget.file!.path) + : mime.lookupMimeType('', headerBytes: widget.bytes); + } + + @override + Widget build(BuildContext context) { + return switch (mimeType) { + "application/pdf" => SizedBox( + width: widget.width, + height: widget.height, + child: Center( + child: FutureBuilder( + future: widget.file?.readAsBytes().then(_convertPdfToPng) ?? + _convertPdfToPng(widget.bytes!), + builder: (context, snapshot) { + if (!snapshot.hasData) { + return const SizedBox.shrink(); + } + return ColoredBox( + color: Colors.white, + child: Image.memory( + snapshot.data!, + alignment: Alignment.topCenter, + fit: widget.fit, + width: widget.width, + height: widget.height, + ), + ); + }, + ), + ), + ), + "image/png" || + "image/jpeg" || + "image/tiff" || + "image/gif" || + "image/webp" => + widget.file != null + ? Image.file( + widget.file!, + fit: widget.fit, + width: widget.width, + height: widget.height, + ) + : Image.memory( + widget.bytes!, + fit: widget.fit, + width: widget.width, + height: widget.height, + ), + "text/plain" => const Center( + child: Text(".txt"), + ), + _ => const Icon(Icons.file_present_outlined), + }; + } + + // send pdfFile as params + Future _convertPdfToPng(Uint8List bytes) async { + final info = await Printing.info(); + if (!info.canRaster) { + return kTransparentImage; + } + final raster = await Printing.raster(bytes, pages: [0], dpi: 72).first; + return raster.toPng(); + } +} diff --git a/lib/features/sharing/view/widgets/upload_queue_shell.dart b/lib/features/sharing/view/widgets/upload_queue_shell.dart new file mode 100644 index 0000000..05c384f --- /dev/null +++ b/lib/features/sharing/view/widgets/upload_queue_shell.dart @@ -0,0 +1,195 @@ +import 'dart:async'; +import 'dart:io'; + +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:fluttertoast/fluttertoast.dart'; +import 'package:hive/hive.dart'; +import 'package:paperless_api/paperless_api.dart'; +import 'package:paperless_mobile/core/config/hive/hive_config.dart'; +import 'package:paperless_mobile/core/config/hive/hive_extensions.dart'; +import 'package:paperless_mobile/core/database/tables/local_user_account.dart'; +import 'package:paperless_mobile/features/document_upload/view/document_upload_preparation_page.dart'; +import 'package:paperless_mobile/features/notifications/services/local_notification_service.dart'; +import 'package:paperless_mobile/features/sharing/cubit/receive_share_cubit.dart'; +import 'package:paperless_mobile/features/sharing/view/dialog/discard_shared_file_dialog.dart'; +import 'package:paperless_mobile/features/tasks/cubit/task_status_cubit.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/scanner_route.dart'; +import 'package:paperless_mobile/routes/typed/branches/upload_queue_route.dart'; +import 'package:path/path.dart' as p; +import 'package:receive_sharing_intent/receive_sharing_intent.dart'; + +class UploadQueueShell extends StatefulWidget { + final Widget child; + const UploadQueueShell({super.key, required this.child}); + + @override + State createState() => _UploadQueueShellState(); +} + +class _UploadQueueShellState extends State { + StreamSubscription? _subscription; + + @override + void initState() { + super.initState(); + ReceiveSharingIntent.getInitialMedia().then(_onReceiveSharedFiles); + _subscription = + ReceiveSharingIntent.getMediaStream().listen(_onReceiveSharedFiles); + + // WidgetsBinding.instance.addPostFrameCallback((timeStamp) { + // context.read().loadFromConsumptionDirectory( + // userId: context.read().id, + // ); + // final state = context.read().state; + // print("Current state is " + state.toString()); + // final files = state.files; + // if (files.isNotEmpty) { + // showSnackBar( + // context, + // "You have ${files.length} shared files waiting to be uploaded.", + // action: SnackBarActionConfig( + // label: "Show me", + // onPressed: () { + // UploadQueueRoute().push(context); + // }, + // ), + // ); + // // showDialog( + // // context: context, + // // builder: (context) => AlertDialog( + // // title: Text("Pending files"), + // // content: Text( + // // "You have ${files.length} files waiting to be uploaded.", + // // ), + // // actions: [ + // // TextButton( + // // child: Text(S.of(context)!.gotIt), + // // onPressed: () { + // // Navigator.pop(context); + // // UploadQueueRoute().push(context); + // // }, + // // ), + // // ], + // // ), + // // ); + // } + // }); + } + + @override + void didChangeDependencies() { + super.didChangeDependencies(); + context.read().addListener(_onTasksChanged); + } + + void _onTasksChanged() { + final taskNotifier = context.read(); + for (var task in taskNotifier.value.values) { + context.read().notifyTaskChanged(task); + } + } + + void _onReceiveSharedFiles(List sharedFiles) async { + final files = sharedFiles.map((file) => File(file.path)).toList(); + + if (files.isNotEmpty) { + final userId = context.read().id; + final notifier = context.read(); + await notifier.addFiles( + files: files, + userId: userId, + ); + final localFiles = notifier.pendingFiles; + for (int i = 0; i < localFiles.length; i++) { + final file = localFiles[i]; + await consumeLocalFile( + context, + file: file, + userId: userId, + exitAppAfterConsumed: i == localFiles.length - 1, + ); + } + } + } + + @override + void dispose() { + _subscription?.cancel(); + context.read().removeListener(_onTasksChanged); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return widget.child; + } +} + +Future consumeLocalFile( + BuildContext context, { + required File file, + required String userId, + bool exitAppAfterConsumed = false, +}) async { + final consumptionNotifier = context.read(); + final taskNotifier = context.read(); + final ioFile = File(file.path); + // if (!await ioFile.exists()) { + // Fluttertoast.showToast( + // msg: S.of(context)!.couldNotAccessReceivedFile, + // toastLength: Toast.LENGTH_LONG, + // ); + // } + + final bytes = ioFile.readAsBytes(); + final shouldDirectlyUpload = + Hive.globalSettingsBox.getValue()!.skipDocumentPreprarationOnUpload; + if (shouldDirectlyUpload) { + final taskId = await context.read().create( + await bytes, + filename: p.basename(file.path), + title: p.basenameWithoutExtension(file.path), + ); + consumptionNotifier.discardFile(file, userId: userId); + if (taskId != null) { + taskNotifier.listenToTaskChanges(taskId); + } + } else { + final result = await DocumentUploadRoute( + $extra: bytes, + filename: p.basenameWithoutExtension(file.path), + title: p.basenameWithoutExtension(file.path), + fileExtension: p.extension(file.path), + ).push(context) ?? + DocumentUploadResult(false, null); + + if (result.success) { + await Fluttertoast.showToast( + msg: S.of(context)!.documentSuccessfullyUploadedProcessing, + ); + await consumptionNotifier.discardFile(file, userId: userId); + + if (result.taskId != null) { + taskNotifier.listenToTaskChanges(result.taskId!); + } + if (exitAppAfterConsumed) { + SystemNavigator.pop(); + } + } else { + final shouldDiscard = await showDialog( + context: context, + builder: (context) => DiscardSharedFileDialog(bytes: bytes), + ) ?? + false; + if (shouldDiscard) { + await context + .read() + .discardFile(file, userId: userId); + } + } + } +} diff --git a/lib/features/tasks/cubit/task_status_cubit.dart b/lib/features/tasks/cubit/task_status_cubit.dart index 66d0a87..25dbcd4 100644 --- a/lib/features/tasks/cubit/task_status_cubit.dart +++ b/lib/features/tasks/cubit/task_status_cubit.dart @@ -1,36 +1,26 @@ -import 'package:bloc/bloc.dart'; -import 'package:equatable/equatable.dart'; +import 'package:flutter/material.dart'; import 'package:paperless_api/paperless_api.dart'; -part 'task_status_state.dart'; -class TaskStatusCubit extends Cubit { +class PendingTasksNotifier extends ValueNotifier> { final PaperlessTasksApi _api; - TaskStatusCubit(this._api) : super(const TaskStatusState()); + PendingTasksNotifier(this._api) : super({}); void listenToTaskChanges(String taskId) { - _api - .listenForTaskChanges(taskId) - .forEach( - (element) => emit( - TaskStatusState( - isListening: true, - task: element, - ), - ), - ) - .whenComplete(() => emit(state.copyWith(isListening: false))); - } - - Future acknowledgeCurrentTask() async { - if (state.task == null) { - return; - } - final task = await _api.acknowledgeTask(state.task!); - emit( - state.copyWith( - task: task, - isListening: false, - ), + _api.listenForTaskChanges(taskId).forEach((task) { + value = {...value, taskId: task}; + notifyListeners(); + }).whenComplete( + () { + value = value..remove(taskId); + notifyListeners(); + }, ); } + + Future acknowledgeTasks(Iterable taskIds) async { + final tasks = value.values.where((task) => taskIds.contains(task.taskId)); + await Future.wait([for (var task in tasks) _api.acknowledgeTask(task)]); + value = value..removeWhere((key, value) => taskIds.contains(key)); + notifyListeners(); + } } diff --git a/lib/features/tasks/cubit/task_status_state.dart b/lib/features/tasks/cubit/task_status_state.dart deleted file mode 100644 index 163d3db..0000000 --- a/lib/features/tasks/cubit/task_status_state.dart +++ /dev/null @@ -1,31 +0,0 @@ -part of 'task_status_cubit.dart'; - -class TaskStatusState extends Equatable { - final Task? task; - final bool isListening; - - const TaskStatusState({ - this.task, - this.isListening = false, - }); - - bool get isSuccess => task?.status == TaskStatus.success; - - bool get isAcknowledged => task?.acknowledged ?? false; - - String? get taskId => task?.taskId; - - @override - List get props => [task, isListening]; - - TaskStatusState copyWith({ - Task? task, - bool? isListening, - bool? isAcknowledged, - }) { - return TaskStatusState( - task: task ?? this.task, - isListening: isListening ?? this.isListening, - ); - } -} diff --git a/lib/l10n/intl_ca.arb b/lib/l10n/intl_ca.arb index b631de0..e170bb7 100644 --- a/lib/l10n/intl_ca.arb +++ b/lib/l10n/intl_ca.arb @@ -865,11 +865,11 @@ "@missingPermissions": { "description": "Message shown in a snackbar when a user without the reequired permissions performs an action." }, - "editView": "Edit View", + "editView": "Editar Vista", "@editView": { "description": "Title of the edit saved view page" }, - "donate": "Donate", + "donate": "Donar", "@donate": { "description": "Label of the in-app donate button" }, @@ -877,23 +877,23 @@ "@donationDialogContent": { "description": "Text displayed in the donation dialog" }, - "noDocumentsFound": "No documents found.", + "noDocumentsFound": "Sense Documents trobats.", "@noDocumentsFound": { "description": "Message shown when no documents were found." }, - "couldNotDeleteCorrespondent": "Could not delete correspondent, please try again.", + "couldNotDeleteCorrespondent": "No es pot esborrar corresponsal, torna a provar.", "@couldNotDeleteCorrespondent": { "description": "Message shown in snackbar when a correspondent could not be deleted." }, - "couldNotDeleteDocumentType": "Could not delete document type, please try again.", + "couldNotDeleteDocumentType": "No es pot esborrar document, prova de nou.", "@couldNotDeleteDocumentType": { "description": "Message shown when a document type could not be deleted" }, - "couldNotDeleteTag": "Could not delete tag, please try again.", + "couldNotDeleteTag": "No es pot esborrar etiqueta, prova de nou.", "@couldNotDeleteTag": { "description": "Message shown when a tag could not be deleted" }, - "couldNotDeleteStoragePath": "Could not delete storage path, please try again.", + "couldNotDeleteStoragePath": "No es pot esborrar ruta emmagatzematge, prova de nou.", "@couldNotDeleteStoragePath": { "description": "Message shown when a storage path could not be deleted" }, @@ -938,7 +938,7 @@ "@savedViewSuccessfullyUpdated": { "description": "Message shown when a saved view was successfully updated." }, - "discardChanges": "Discard changes?", + "discardChanges": "Descartar canvis?", "@discardChanges": { "description": "Title of the alert dialog shown when a user tries to close a view with unsaved changes." }, @@ -950,11 +950,11 @@ "@createFromCurrentFilter": { "description": "Tooltip of the \"New saved view\" button" }, - "home": "Home", + "home": "Inici", "@home": { "description": "Label of the \"Home\" route" }, - "welcomeUser": "Welcome, {name}!", + "welcomeUser": "Benvingut {name}!", "@welcomeUser": { "description": "Top message shown on the home page" }, @@ -962,16 +962,23 @@ "@noSavedViewOnHomepageHint": { "description": "Message shown when there is no saved view to display on the home page." }, - "statistics": "Statistics", - "documentsInInbox": "Documents in inbox", + "statistics": "Estadí­stiques", + "documentsInInbox": "Document safata", "totalDocuments": "Total documents", - "totalCharacters": "Total characters", - "showAll": "Show all", + "totalCharacters": "Caràcters Totals", + "showAll": "Mostra tot", "@showAll": { "description": "Button label shown on a saved view preview to open this view in the documents page" }, - "userAlreadyExists": "This user already exists.", + "userAlreadyExists": "Usuari ja esisteix.", "@userAlreadyExists": { "description": "Error message shown when the user tries to add an already existing account." - } + }, + "youDidNotSaveAnyViewsYet": "You did not save any views yet, create one and it will be shown here.", + "@youDidNotSaveAnyViewsYet": { + "description": "Message shown when there are no saved views yet." + }, + "tryAgain": "Try again", + "discardFile": "Discard file?", + "discard": "Discard" } \ No newline at end of file diff --git a/lib/l10n/intl_cs.arb b/lib/l10n/intl_cs.arb index 82f70c3..015140a 100644 --- a/lib/l10n/intl_cs.arb +++ b/lib/l10n/intl_cs.arb @@ -973,5 +973,12 @@ "userAlreadyExists": "This user already exists.", "@userAlreadyExists": { "description": "Error message shown when the user tries to add an already existing account." - } + }, + "youDidNotSaveAnyViewsYet": "You did not save any views yet, create one and it will be shown here.", + "@youDidNotSaveAnyViewsYet": { + "description": "Message shown when there are no saved views yet." + }, + "tryAgain": "Try again", + "discardFile": "Discard file?", + "discard": "Discard" } \ No newline at end of file diff --git a/lib/l10n/intl_de.arb b/lib/l10n/intl_de.arb index 8a31711..97f090a 100644 --- a/lib/l10n/intl_de.arb +++ b/lib/l10n/intl_de.arb @@ -973,5 +973,12 @@ "userAlreadyExists": "Dieser Nutzer existiert bereits.", "@userAlreadyExists": { "description": "Error message shown when the user tries to add an already existing account." - } + }, + "youDidNotSaveAnyViewsYet": "Du hast noch keine Ansichten gespeichert. Erstelle eine neue Ansicht, und sie wird hier angezeigt.", + "@youDidNotSaveAnyViewsYet": { + "description": "Message shown when there are no saved views yet." + }, + "tryAgain": "Erneut versuchen", + "discardFile": "Datei verwerfen?", + "discard": "Verwerfen" } \ No newline at end of file diff --git a/lib/l10n/intl_en.arb b/lib/l10n/intl_en.arb index 1e00854..db1778b 100644 --- a/lib/l10n/intl_en.arb +++ b/lib/l10n/intl_en.arb @@ -973,5 +973,12 @@ "userAlreadyExists": "This user already exists.", "@userAlreadyExists": { "description": "Error message shown when the user tries to add an already existing account." - } + }, + "youDidNotSaveAnyViewsYet": "You did not save any views yet, create one and it will be shown here.", + "@youDidNotSaveAnyViewsYet": { + "description": "Message shown when there are no saved views yet." + }, + "tryAgain": "Try again", + "discardFile": "Discard file?", + "discard": "Discard" } \ No newline at end of file diff --git a/lib/l10n/intl_es.arb b/lib/l10n/intl_es.arb index 6096a3f..3e0224f 100644 --- a/lib/l10n/intl_es.arb +++ b/lib/l10n/intl_es.arb @@ -973,5 +973,12 @@ "userAlreadyExists": "This user already exists.", "@userAlreadyExists": { "description": "Error message shown when the user tries to add an already existing account." - } + }, + "youDidNotSaveAnyViewsYet": "You did not save any views yet, create one and it will be shown here.", + "@youDidNotSaveAnyViewsYet": { + "description": "Message shown when there are no saved views yet." + }, + "tryAgain": "Try again", + "discardFile": "Discard file?", + "discard": "Discard" } \ No newline at end of file diff --git a/lib/l10n/intl_fr.arb b/lib/l10n/intl_fr.arb index 308916e..778733d 100644 --- a/lib/l10n/intl_fr.arb +++ b/lib/l10n/intl_fr.arb @@ -973,5 +973,12 @@ "userAlreadyExists": "This user already exists.", "@userAlreadyExists": { "description": "Error message shown when the user tries to add an already existing account." - } + }, + "youDidNotSaveAnyViewsYet": "You did not save any views yet, create one and it will be shown here.", + "@youDidNotSaveAnyViewsYet": { + "description": "Message shown when there are no saved views yet." + }, + "tryAgain": "Try again", + "discardFile": "Discard file?", + "discard": "Discard" } \ No newline at end of file diff --git a/lib/l10n/intl_pl.arb b/lib/l10n/intl_pl.arb index cf000d6..9153d4c 100644 --- a/lib/l10n/intl_pl.arb +++ b/lib/l10n/intl_pl.arb @@ -973,5 +973,12 @@ "userAlreadyExists": "This user already exists.", "@userAlreadyExists": { "description": "Error message shown when the user tries to add an already existing account." - } + }, + "youDidNotSaveAnyViewsYet": "You did not save any views yet, create one and it will be shown here.", + "@youDidNotSaveAnyViewsYet": { + "description": "Message shown when there are no saved views yet." + }, + "tryAgain": "Try again", + "discardFile": "Discard file?", + "discard": "Discard" } \ No newline at end of file diff --git a/lib/l10n/intl_ru.arb b/lib/l10n/intl_ru.arb index 8d82ccf..f89e99d 100644 --- a/lib/l10n/intl_ru.arb +++ b/lib/l10n/intl_ru.arb @@ -5,9 +5,9 @@ "name": {} } }, - "addAnotherAccount": "Добавить другую учетную запись", + "addAnotherAccount": "Добавить другой аккаунт", "@addAnotherAccount": {}, - "account": "Учётная запись", + "account": "Аккаунт", "@account": {}, "addCorrespondent": "Новый корреспондент", "@addCorrespondent": { @@ -29,949 +29,956 @@ "@aboutThisApp": { "description": "Label for about this app tile displayed in the drawer" }, - "loggedInAs": "Logged in as {name}", + "loggedInAs": "Вход выполнен как {name}", "@loggedInAs": { "placeholders": { "name": {} } }, - "disconnect": "Disconnect", + "disconnect": "Отключиться", "@disconnect": { "description": "Logout button label" }, - "reportABug": "Report a Bug", + "reportABug": "Сообщить об ошибке", "@reportABug": {}, - "settings": "Settings", + "settings": "Настройки", "@settings": {}, - "authenticateOnAppStart": "Authenticate on app start", + "authenticateOnAppStart": "Аутентифицироваться при запуске приложения", "@authenticateOnAppStart": { "description": "Description of the biometric authentication settings tile" }, - "biometricAuthentication": "Biometric authentication", + "biometricAuthentication": "Биометрическая аутентификация", "@biometricAuthentication": {}, - "authenticateToToggleBiometricAuthentication": "{mode, select, enable{Authenticate to enable biometric authentication} disable{Authenticate to disable biometric authentication} other{}}", + "authenticateToToggleBiometricAuthentication": "{mode, select, enable{Авторизуйтесь для включения биометрической аутентификации} disable{Авторизуйтесь для отключения биометрической аутентификации} other{}}", "@authenticateToToggleBiometricAuthentication": { "placeholders": { "mode": {} } }, - "documents": "Documents", + "documents": "Документы", "@documents": {}, - "inbox": "Inbox", + "inbox": "Входящие", "@inbox": {}, - "labels": "Labels", + "labels": "Метки", "@labels": {}, - "scanner": "Scanner", + "scanner": "Сканер", "@scanner": {}, - "startTyping": "Start typing...", + "startTyping": "Начните вводить текст...", "@startTyping": {}, - "doYouReallyWantToDeleteThisView": "Do you really want to delete this view?", + "doYouReallyWantToDeleteThisView": "Вы действительно хотите удалить этот вид?", "@doYouReallyWantToDeleteThisView": {}, "deleteView": "", "@deleteView": {}, - "addedAt": "Added at", + "addedAt": "Добавлено в", "@addedAt": {}, - "archiveSerialNumber": "Archive Serial Number", + "archiveSerialNumber": "Серийный номер архива", "@archiveSerialNumber": {}, "asn": "ASN", "@asn": {}, - "correspondent": "Correspondent", + "correspondent": "Корреспондент", "@correspondent": {}, - "createdAt": "Created at", + "createdAt": "Создано в", "@createdAt": {}, - "documentSuccessfullyDeleted": "Document successfully deleted.", + "documentSuccessfullyDeleted": "Документ успешно удален.", "@documentSuccessfullyDeleted": {}, - "assignAsn": "Assign ASN", + "assignAsn": "Назначить ASN", "@assignAsn": {}, - "deleteDocumentTooltip": "Delete", + "deleteDocumentTooltip": "Удалить", "@deleteDocumentTooltip": { "description": "Tooltip shown for the delete button on details page" }, - "downloadDocumentTooltip": "Download", + "downloadDocumentTooltip": "Скачать", "@downloadDocumentTooltip": { "description": "Tooltip shown for the download button on details page" }, - "editDocumentTooltip": "Edit", + "editDocumentTooltip": "Редактировать", "@editDocumentTooltip": { "description": "Tooltip shown for the edit button on details page" }, - "loadFullContent": "Load full content", + "loadFullContent": "Загрузить полный контент", "@loadFullContent": {}, - "noAppToDisplayPDFFilesFound": "No app to display PDF files found!", + "noAppToDisplayPDFFilesFound": "Не найдено приложений для отображения PDF-файлов!", "@noAppToDisplayPDFFilesFound": {}, - "openInSystemViewer": "Open in system viewer", + "openInSystemViewer": "Открыть в системном просмотрщике", "@openInSystemViewer": {}, - "couldNotOpenFilePermissionDenied": "Could not open file: Permission denied.", + "couldNotOpenFilePermissionDenied": "Не удалось открыть файл: Отказано в разрешении.", "@couldNotOpenFilePermissionDenied": {}, - "previewTooltip": "Preview", + "previewTooltip": "Предпросмотр", "@previewTooltip": { "description": "Tooltip shown for the preview button on details page" }, - "shareTooltip": "Share", + "shareTooltip": "Поделиться", "@shareTooltip": { "description": "Tooltip shown for the share button on details page" }, - "similarDocuments": "Similar Documents", + "similarDocuments": "Похожие документы", "@similarDocuments": { "description": "Label shown in the tabbar on details page" }, - "content": "Content", + "content": "Контент", "@content": { "description": "Label shown in the tabbar on details page" }, - "metaData": "Meta Data", + "metaData": "Метаданные", "@metaData": { "description": "Label shown in the tabbar on details page" }, - "overview": "Overview", + "overview": "Обзор", "@overview": { "description": "Label shown in the tabbar on details page" }, - "documentType": "Document Type", + "documentType": "Тип документа", "@documentType": {}, - "archivedPdf": "Archived (pdf)", + "archivedPdf": "Архивировано (pdf)", "@archivedPdf": { "description": "Option to chose when downloading a document" }, - "chooseFiletype": "Choose filetype", + "chooseFiletype": "Выберите тип файла", "@chooseFiletype": {}, - "original": "Original", + "original": "Оригинал", "@original": { "description": "Option to chose when downloading a document" }, - "documentSuccessfullyDownloaded": "Document successfully downloaded.", + "documentSuccessfullyDownloaded": "Документ успешно загружен.", "@documentSuccessfullyDownloaded": {}, - "suggestions": "Suggestions: ", + "suggestions": "Предложения: ", "@suggestions": {}, - "editDocument": "Edit Document", + "editDocument": "Редактировать документ", "@editDocument": {}, - "advanced": "Advanced", + "advanced": "Дополнительно", "@advanced": {}, - "apply": "Apply", + "apply": "Применить", "@apply": {}, - "extended": "Extended", + "extended": "Расширенный", "@extended": {}, - "titleAndContent": "Title & Content", + "titleAndContent": "Название и Контент", "@titleAndContent": {}, - "title": "Title", + "title": "Название", "@title": {}, - "reset": "Reset", + "reset": "Сброс", "@reset": {}, - "filterDocuments": "Filter Documents", + "filterDocuments": "Фильтр документов", "@filterDocuments": { "description": "Title of the document filter" }, - "originalMD5Checksum": "Original MD5-Checksum", + "originalMD5Checksum": "Оригинальная MD5-контрольная сумма", "@originalMD5Checksum": {}, - "mediaFilename": "Media Filename", + "mediaFilename": "Название медиафайла", "@mediaFilename": {}, - "originalFileSize": "Original File Size", + "originalFileSize": "Оригинальный размер файла", "@originalFileSize": {}, - "originalMIMEType": "Original MIME-Type", + "originalMIMEType": "Оригинальный MIME-тип", "@originalMIMEType": {}, - "modifiedAt": "Modified at", + "modifiedAt": "Изменено в", "@modifiedAt": {}, - "preview": "Preview", + "preview": "Предпросмотр", "@preview": { "description": "Title of the document preview page" }, - "scanADocument": "Scan a document", + "scanADocument": "Сканировать документ", "@scanADocument": {}, - "noDocumentsScannedYet": "No documents scanned yet.", + "noDocumentsScannedYet": "Документы еще не сканированы.", "@noDocumentsScannedYet": {}, - "or": "or", + "or": "или", "@or": { "description": "Used on the scanner page between both main actions when no scans have been captured." }, - "deleteAllScans": "Delete all scans", + "deleteAllScans": "Удалить все сканирования", "@deleteAllScans": {}, - "uploadADocumentFromThisDevice": "Upload a document from this device", + "uploadADocumentFromThisDevice": "Загрузить документ с этого устройства", "@uploadADocumentFromThisDevice": { "description": "Button label on scanner page" }, - "noMatchesFound": "No matches found.", + "noMatchesFound": "Ничего не найдено.", "@noMatchesFound": { "description": "Displayed when no documents were found in the document search." }, - "removeFromSearchHistory": "Remove from search history?", + "removeFromSearchHistory": "Удалить из истории поиска?", "@removeFromSearchHistory": {}, - "results": "Results", + "results": "Результаты", "@results": { "description": "Label displayed above search results in document search." }, - "searchDocuments": "Search documents", + "searchDocuments": "Поиск документов", "@searchDocuments": {}, - "resetFilter": "Reset filter", + "resetFilter": "Сбросить фильтр", "@resetFilter": {}, - "lastMonth": "Last Month", + "lastMonth": "Прошлый месяц", "@lastMonth": {}, - "last7Days": "Last 7 Days", + "last7Days": "Последние 7 дней", "@last7Days": {}, - "last3Months": "Last 3 Months", + "last3Months": "Последние 3 месяца", "@last3Months": {}, - "lastYear": "Last Year", + "lastYear": "Прошлый год", "@lastYear": {}, - "search": "Search", + "search": "Поиск", "@search": {}, - "documentsSuccessfullyDeleted": "Documents successfully deleted.", + "documentsSuccessfullyDeleted": "Документ успешно удален.", "@documentsSuccessfullyDeleted": {}, - "thereSeemsToBeNothingHere": "There seems to be nothing here...", + "thereSeemsToBeNothingHere": "Похоже, здесь ничего нет...", "@thereSeemsToBeNothingHere": {}, - "oops": "Oops.", + "oops": "Упс.", "@oops": {}, - "newDocumentAvailable": "New document available!", + "newDocumentAvailable": "Доступен новый документ!", "@newDocumentAvailable": {}, - "orderBy": "Order By", + "orderBy": "Упорядочить по", "@orderBy": {}, - "thisActionIsIrreversibleDoYouWishToProceedAnyway": "This action is irreversible. Do you wish to proceed anyway?", + "thisActionIsIrreversibleDoYouWishToProceedAnyway": "Это действие необратимо. Все равно хотите продолжить?", "@thisActionIsIrreversibleDoYouWishToProceedAnyway": {}, - "confirmDeletion": "Confirm deletion", + "confirmDeletion": "Подтвердить удаление", "@confirmDeletion": {}, - "areYouSureYouWantToDeleteTheFollowingDocuments": "{count, plural, one{Are you sure you want to delete the following document?} other{Are you sure you want to delete the following documents?}}", + "areYouSureYouWantToDeleteTheFollowingDocuments": "{count, plural, one{Вы уверены, что хотите удалить следующий документ?} few {Вы уверены, что хотите удалить следующие документы?} many {Вы уверены, что хотите удалить следующие документы?} other{Вы уверены, что хотите удалить следующие документы?}}", "@areYouSureYouWantToDeleteTheFollowingDocuments": { "placeholders": { "count": {} } }, - "countSelected": "{count} selected", + "countSelected": "{count} выбрано", "@countSelected": { "description": "Displayed in the appbar when at least one document is selected.", "placeholders": { "count": {} } }, - "storagePath": "Storage Path", + "storagePath": "Путь хранения", "@storagePath": {}, - "prepareDocument": "Prepare document", + "prepareDocument": "Подготовить документ", "@prepareDocument": {}, - "tags": "Tags", + "tags": "Теги", "@tags": {}, - "documentSuccessfullyUpdated": "Document successfully updated.", + "documentSuccessfullyUpdated": "Документ успешно обновлен.", "@documentSuccessfullyUpdated": {}, - "fileName": "File Name", + "fileName": "Имя файла", "@fileName": {}, - "synchronizeTitleAndFilename": "Synchronize title and filename", + "synchronizeTitleAndFilename": "Синхронизировать название и имя файла", "@synchronizeTitleAndFilename": {}, - "reload": "Reload", + "reload": "Перезагрузить", "@reload": {}, - "documentSuccessfullyUploadedProcessing": "Document successfully uploaded, processing...", + "documentSuccessfullyUploadedProcessing": "Документ успешно загружен, обработка...", "@documentSuccessfullyUploadedProcessing": {}, - "deleteLabelWarningText": "This label contains references to other documents. By deleting this label, all references will be removed. Continue?", + "deleteLabelWarningText": "Эта метка содержит ссылки на другие документы. Удаляя эту метку, все ссылки будут удалены. Продолжить?", "@deleteLabelWarningText": {}, - "couldNotAcknowledgeTasks": "Could not acknowledge tasks.", + "couldNotAcknowledgeTasks": "Не удалось подтвердить задания.", "@couldNotAcknowledgeTasks": {}, - "authenticationFailedPleaseTryAgain": "Authentication failed, please try again.", + "authenticationFailedPleaseTryAgain": "Аутентификация не удалась, попробуйте еще раз.", "@authenticationFailedPleaseTryAgain": {}, - "anErrorOccurredWhileTryingToAutocompleteYourQuery": "An error ocurred while trying to autocomplete your query.", + "anErrorOccurredWhileTryingToAutocompleteYourQuery": "Произошла ошибка при попытке автоматического заполнения запроса.", "@anErrorOccurredWhileTryingToAutocompleteYourQuery": {}, - "biometricAuthenticationFailed": "Biometric authentication failed.", + "biometricAuthenticationFailed": "Биометрическая аутентификация провалена.", "@biometricAuthenticationFailed": {}, - "biometricAuthenticationNotSupported": "Biometric authentication not supported on this device.", + "biometricAuthenticationNotSupported": "Биометрическая аутентификация не поддерживается на этом устройстве.", "@biometricAuthenticationNotSupported": {}, - "couldNotBulkEditDocuments": "Could not bulk edit documents.", + "couldNotBulkEditDocuments": "Не удалось редактировать документы.", "@couldNotBulkEditDocuments": {}, - "couldNotCreateCorrespondent": "Could not create correspondent, please try again.", + "couldNotCreateCorrespondent": "Не удалось создать корреспондента, попробуйте еще раз.", "@couldNotCreateCorrespondent": {}, - "couldNotLoadCorrespondents": "Could not load correspondents.", + "couldNotLoadCorrespondents": "Не удалось загрузить корреспондентов.", "@couldNotLoadCorrespondents": {}, - "couldNotCreateSavedView": "Could not create saved view, please try again.", + "couldNotCreateSavedView": "Не удалось обновить сохраненный вид, попробуйте еще раз.", "@couldNotCreateSavedView": {}, - "couldNotDeleteSavedView": "Could not delete saved view, please try again", + "couldNotDeleteSavedView": "Не удалось обновить сохраненный вид, попробуйте еще раз", "@couldNotDeleteSavedView": {}, - "youAreCurrentlyOffline": "You are currently offline. Please make sure you are connected to the internet.", + "youAreCurrentlyOffline": "В настоящее время вы не в сети. Убедитесь, что вы подключены к Интернету.", "@youAreCurrentlyOffline": {}, - "couldNotAssignArchiveSerialNumber": "Could not assign archive serial number.", + "couldNotAssignArchiveSerialNumber": "Не удалось присвоить архивный серийный номер.", "@couldNotAssignArchiveSerialNumber": {}, - "couldNotDeleteDocument": "Could not delete document, please try again.", + "couldNotDeleteDocument": "Не удалось удалить документ, попробуйте еще раз.", "@couldNotDeleteDocument": {}, - "couldNotLoadDocuments": "Could not load documents, please try again.", + "couldNotLoadDocuments": "Не удалось загрузить документы, попробуйте еще раз.", "@couldNotLoadDocuments": {}, - "couldNotLoadDocumentPreview": "Could not load document preview.", + "couldNotLoadDocumentPreview": "Не удалось загрузить предпросмотр документа.", "@couldNotLoadDocumentPreview": {}, - "couldNotCreateDocument": "Could not create document, please try again.", + "couldNotCreateDocument": "Не удалось создать документ, попробуйте еще раз.", "@couldNotCreateDocument": {}, - "couldNotLoadDocumentTypes": "Could not load document types, please try again.", + "couldNotLoadDocumentTypes": "Не удалось загрузить типы документов, попробуйте еще раз.", "@couldNotLoadDocumentTypes": {}, - "couldNotUpdateDocument": "Could not update document, please try again.", + "couldNotUpdateDocument": "Не удалось обновить документ, попробуйте еще раз.", "@couldNotUpdateDocument": {}, - "couldNotUploadDocument": "Could not upload document, please try again.", + "couldNotUploadDocument": "Не удалось загрузить документ, попробуйте еще раз.", "@couldNotUploadDocument": {}, - "invalidCertificateOrMissingPassphrase": "Invalid certificate or missing passphrase, please try again", + "invalidCertificateOrMissingPassphrase": "Неверный сертификат или отсутствующая ключевая фраза, пожалуйста попробуйте еще раз", "@invalidCertificateOrMissingPassphrase": {}, - "couldNotLoadSavedViews": "Could not load saved views.", + "couldNotLoadSavedViews": "Не удалось загрузить сохраненные виды.", "@couldNotLoadSavedViews": {}, - "aClientCertificateWasExpectedButNotSent": "A client certificate was expected but not sent. Please provide a valid client certificate.", + "aClientCertificateWasExpectedButNotSent": "Ожидался сертификат клиента, но он не отправлен. Пожалуйста, предоставьте действительный клиентский сертификат.", "@aClientCertificateWasExpectedButNotSent": {}, - "userIsNotAuthenticated": "User is not authenticated.", + "userIsNotAuthenticated": "Пользователь не аутентифицирован.", "@userIsNotAuthenticated": {}, - "requestTimedOut": "The request to the server timed out.", + "requestTimedOut": "Время ожидания запроса на сервер истекло.", "@requestTimedOut": {}, - "anErrorOccurredRemovingTheScans": "An error occurred removing the scans.", + "anErrorOccurredRemovingTheScans": "Произошла ошибка при удалении сканирования.", "@anErrorOccurredRemovingTheScans": {}, - "couldNotReachYourPaperlessServer": "Could not reach your Paperless server, is it up and running?", + "couldNotReachYourPaperlessServer": "Не удалось получить доступ к вашему серверу Paperless, он работает?", "@couldNotReachYourPaperlessServer": {}, - "couldNotLoadSimilarDocuments": "Could not load similar documents.", + "couldNotLoadSimilarDocuments": "Не удалось загрузить похожие документы.", "@couldNotLoadSimilarDocuments": {}, - "couldNotCreateStoragePath": "Could not create storage path, please try again.", + "couldNotCreateStoragePath": "Не удалось создать путь к хранилищу, пожалуйста, попробуйте еще раз.", "@couldNotCreateStoragePath": {}, - "couldNotLoadStoragePaths": "Could not load storage paths.", + "couldNotLoadStoragePaths": "Не удалось загрузить пути хранилища.", "@couldNotLoadStoragePaths": {}, - "couldNotLoadSuggestions": "Could not load suggestions.", + "couldNotLoadSuggestions": "Не удалось загрузить предложения.", "@couldNotLoadSuggestions": {}, - "couldNotCreateTag": "Could not create tag, please try again.", + "couldNotCreateTag": "Не удалось создать тег, попробуйте еще раз.", "@couldNotCreateTag": {}, - "couldNotLoadTags": "Could not load tags.", + "couldNotLoadTags": "Не удалось загрузить теги.", "@couldNotLoadTags": {}, - "anUnknownErrorOccurred": "An unknown error occurred.", + "anUnknownErrorOccurred": "Произошла неизвестная ошибка.", "@anUnknownErrorOccurred": {}, - "fileFormatNotSupported": "This file format is not supported.", + "fileFormatNotSupported": "Этот формат файла не поддерживается.", "@fileFormatNotSupported": {}, - "report": "REPORT", + "report": "СООБЩИТЬ", "@report": {}, - "absolute": "Absolute", + "absolute": "Абсолютный", "@absolute": {}, - "hintYouCanAlsoSpecifyRelativeValues": "Hint: Apart from concrete dates, you can also specify a time range relative to the current date.", + "hintYouCanAlsoSpecifyRelativeValues": "Подсказка: Помимо конкретных дат, вы можете также указать диапазон времени относительно текущей даты.", "@hintYouCanAlsoSpecifyRelativeValues": { "description": "Displayed in the extended date range picker" }, - "amount": "Amount", + "amount": "Количество", "@amount": {}, - "relative": "Relative", + "relative": "Относительно", "@relative": {}, - "last": "Last", + "last": "Последний", "@last": {}, - "timeUnit": "Time unit", + "timeUnit": "Единица времени", "@timeUnit": {}, - "selectDateRange": "Select date range", + "selectDateRange": "Выберите диапазон дат", "@selectDateRange": {}, - "after": "After", + "after": "После", "@after": {}, - "before": "Before", + "before": "Ранее", "@before": {}, - "days": "{count, plural, zero{days} one{day} other{days}}", + "days": "{count, plural, one{день} few {дней} many {дней} other{дней}}", "@days": { "placeholders": { "count": {} } }, - "lastNDays": "{count, plural, zero{} one{Yesterday} other{Last {count} days}}", + "lastNDays": "{count, plural, one{Вчера} few {Прошло {count} дней} many {Прошло {count} дней} other{Прошло {count} дней}}", "@lastNDays": { "placeholders": { "count": {} } }, - "lastNMonths": "{count, plural, zero{} one{Last month} other{Last {count} months}}", + "lastNMonths": "{count, plural, one{В прошлом месяце} few {Прошло {count} месяцев} many {Прошло {count} месяцев} other{Прошло {count} месяцев}}", "@lastNMonths": { "placeholders": { "count": {} } }, - "lastNWeeks": "{count, plural, zero{} one{Last week} other{Last {count} weeks}}", + "lastNWeeks": "{count, plural, one{На прошлой неделе} few {Прошло {count} недели} many {Прошло {count} недели} other{Прошло {count} недели}}", "@lastNWeeks": { "placeholders": { "count": {} } }, - "lastNYears": "{count, plural, zero{} one{Last year} other{Last {count} years}}", + "lastNYears": "{count, plural, one{В прошлом году} few {Прошло {count} года} many {Прошло {count} года} other{Прошло {count} года}}", "@lastNYears": { "placeholders": { "count": {} } }, - "months": "{count, plural, zero{} one{month} other{months}}", + "months": "{count, plural, one{месяц} few {месяцы} many {месяцы} other{месяцы}}", "@months": { "placeholders": { "count": {} } }, - "weeks": "{count, plural, zero{} one{week} other{weeks}}", + "weeks": "{count, plural, one{неделя} few {недели} many {недели} other{недели}}", "@weeks": { "placeholders": { "count": {} } }, - "years": "{count, plural, zero{} one{year} other{years}}", + "years": "{count, plural, one{год} few {года} many {года} other{года}}", "@years": { "placeholders": { "count": {} } }, - "gotIt": "Got it!", + "gotIt": "Понял!", "@gotIt": {}, - "cancel": "Cancel", + "cancel": "Отменить", "@cancel": {}, - "close": "Close", + "close": "Закрыть", "@close": {}, - "create": "Create", + "create": "Создать", "@create": {}, - "delete": "Delete", + "delete": "Удалить", "@delete": {}, - "edit": "Edit", + "edit": "Редактировать", "@edit": {}, - "ok": "Ok", + "ok": "Ок", "@ok": {}, - "save": "Save", + "save": "Сохранить", "@save": {}, - "select": "Select", + "select": "Выбрать", "@select": {}, - "saveChanges": "Save changes", + "saveChanges": "Сохранить изменения", "@saveChanges": {}, - "upload": "Upload", + "upload": "Загрузить", "@upload": {}, - "youreOffline": "You're offline.", + "youreOffline": "Вы не в сети.", "@youreOffline": {}, - "deleteDocument": "Delete document", + "deleteDocument": "Удалить документ", "@deleteDocument": { "description": "Used as an action label on each inbox item" }, - "removeDocumentFromInbox": "Document removed from inbox.", + "removeDocumentFromInbox": "Документ удален из \"Входящие\".", "@removeDocumentFromInbox": {}, - "areYouSureYouWantToMarkAllDocumentsAsSeen": "Are you sure you want to mark all documents as seen? This will perform a bulk edit operation removing all inbox tags from the documents. This action is not reversible! Are you sure you want to continue?", + "areYouSureYouWantToMarkAllDocumentsAsSeen": "Вы уверены, что хотите отметить все документы как просмотренные? Это выполнит операцию массового редактирования, удалив все входящие теги из документов. Это действие необратимо! Вы уверены, что хотите продолжить?", "@areYouSureYouWantToMarkAllDocumentsAsSeen": {}, - "markAllAsSeen": "Mark all as seen?", + "markAllAsSeen": "Отметить все как просмотренные?", "@markAllAsSeen": {}, - "allSeen": "All seen", + "allSeen": "Все просмотренные", "@allSeen": {}, - "markAsSeen": "Mark as seen", + "markAsSeen": "Отметить все как просмотренные", "@markAsSeen": {}, - "refresh": "Refresh", + "refresh": "Обновить", "@refresh": {}, - "youDoNotHaveUnseenDocuments": "You do not have unseen documents.", + "youDoNotHaveUnseenDocuments": "У вас нет непросмотренных документов.", "@youDoNotHaveUnseenDocuments": {}, - "quickAction": "Quick Action", + "quickAction": "Быстрое действие", "@quickAction": {}, - "suggestionSuccessfullyApplied": "Suggestion successfully applied.", + "suggestionSuccessfullyApplied": "Предложение успешно применено.", "@suggestionSuccessfullyApplied": {}, - "today": "Today", + "today": "Сегодня", "@today": {}, - "undo": "Undo", + "undo": "Отменить", "@undo": {}, - "nUnseen": "{count} unseen", + "nUnseen": "{count} непросмотренных", "@nUnseen": { "placeholders": { "count": {} } }, - "swipeLeftToMarkADocumentAsSeen": "Hint: Swipe left to mark a document as seen and remove all inbox tags from the document.", + "swipeLeftToMarkADocumentAsSeen": "Подсказка: Проведите пальцем влево, чтобы отметить документ как просмотренный и удалить все входящие теги из документа.", "@swipeLeftToMarkADocumentAsSeen": {}, - "yesterday": "Yesterday", + "yesterday": "Вчера", "@yesterday": {}, - "anyAssigned": "Any assigned", + "anyAssigned": "Любые назначенные", "@anyAssigned": {}, - "noItemsFound": "No items found!", + "noItemsFound": "Элементы не найдены!", "@noItemsFound": {}, - "caseIrrelevant": "Case Irrelevant", + "caseIrrelevant": "Дело не имеет отношение", "@caseIrrelevant": {}, - "matchingAlgorithm": "Matching Algorithm", + "matchingAlgorithm": "Алгоритм подбора", "@matchingAlgorithm": {}, - "match": "Match", + "match": "Соответствует", "@match": {}, - "name": "Name", + "name": "Имя", "@name": {}, - "notAssigned": "Not assigned", + "notAssigned": "Не назначенные", "@notAssigned": {}, - "addNewCorrespondent": "Add new correspondent", + "addNewCorrespondent": "Добавить нового корреспондента", "@addNewCorrespondent": {}, - "noCorrespondentsSetUp": "You don't seem to have any correspondents set up.", + "noCorrespondentsSetUp": "Похоже, у вас нет настроенных корреспондентов.", "@noCorrespondentsSetUp": {}, - "correspondents": "Correspondents", + "correspondents": "Корреспонденты", "@correspondents": {}, - "addNewDocumentType": "Add new document type", + "addNewDocumentType": "Добавить новый тип документа", "@addNewDocumentType": {}, - "noDocumentTypesSetUp": "You don't seem to have any document types set up.", + "noDocumentTypesSetUp": "Похоже, у вас нет каких-либо типов документов.", "@noDocumentTypesSetUp": {}, - "documentTypes": "Document Types", + "documentTypes": "Типы документов", "@documentTypes": {}, - "addNewStoragePath": "Add new storage path", + "addNewStoragePath": "Добавить новый путь к хранилищу", "@addNewStoragePath": {}, - "noStoragePathsSetUp": "You don't seem to have any storage paths set up.", + "noStoragePathsSetUp": "Похоже, у вас нет никаких путей хранения.", "@noStoragePathsSetUp": {}, - "storagePaths": "Storage Paths", + "storagePaths": "Пути хранения", "@storagePaths": {}, - "addNewTag": "Add new tag", + "addNewTag": "Добавить новый тег", "@addNewTag": {}, - "noTagsSetUp": "You don't seem to have any tags set up.", + "noTagsSetUp": "Похоже, у вас нет настроенных тегов.", "@noTagsSetUp": {}, - "linkedDocuments": "Linked Documents", + "linkedDocuments": "Связанные документы", "@linkedDocuments": {}, - "advancedSettings": "Advanced Settings", + "advancedSettings": "Дополнительные настройки", "@advancedSettings": {}, - "passphrase": "Passphrase", + "passphrase": "Ключевая фраза", "@passphrase": {}, - "configureMutualTLSAuthentication": "Configure Mutual TLS Authentication", + "configureMutualTLSAuthentication": "Настроить взаимную TLS аутентификацию", "@configureMutualTLSAuthentication": {}, - "invalidCertificateFormat": "Invalid certificate format, only .pfx is allowed", + "invalidCertificateFormat": "Неверный формат сертификата, разрешено только .pfx", "@invalidCertificateFormat": {}, - "clientcertificate": "Client Certificate", + "clientcertificate": "Сертификат клиента", "@clientcertificate": {}, - "selectFile": "Select file...", + "selectFile": "Выберите файл...", "@selectFile": {}, - "continueLabel": "Continue", + "continueLabel": "Продолжить", "@continueLabel": {}, - "incorrectOrMissingCertificatePassphrase": "Incorrect or missing certificate passphrase.", + "incorrectOrMissingCertificatePassphrase": "Неверный или отсутствует пароль сертификата.", "@incorrectOrMissingCertificatePassphrase": {}, - "connect": "Connect", + "connect": "Подключиться", "@connect": {}, - "password": "Password", + "password": "Пароль", "@password": {}, - "passwordMustNotBeEmpty": "Password must not be empty.", + "passwordMustNotBeEmpty": "Пароль не может быть пустым.", "@passwordMustNotBeEmpty": {}, - "connectionTimedOut": "Connection timed out.", + "connectionTimedOut": "Время ожидания истекло.", "@connectionTimedOut": {}, - "loginPageReachabilityMissingClientCertificateText": "A client certificate was expected but not sent. Please provide a certificate.", + "loginPageReachabilityMissingClientCertificateText": "Ожидался сертификат клиента, но он не отправлен. Пожалуйста, предоставьте сертификат.", "@loginPageReachabilityMissingClientCertificateText": {}, - "couldNotEstablishConnectionToTheServer": "Could not establish a connection to the server.", + "couldNotEstablishConnectionToTheServer": "Не удалось установить соединение с сервером.", "@couldNotEstablishConnectionToTheServer": {}, - "connectionSuccessfulylEstablished": "Connection successfully established.", + "connectionSuccessfulylEstablished": "Соединение успешно установлено.", "@connectionSuccessfulylEstablished": {}, - "hostCouldNotBeResolved": "Host could not be resolved. Please check the server address and your internet connection. ", + "hostCouldNotBeResolved": "Хост не может быть решен. Пожалуйста, проверьте адрес сервера и подключение к Интернету. ", "@hostCouldNotBeResolved": {}, - "serverAddress": "Server Address", + "serverAddress": "Адрес сервера", "@serverAddress": {}, - "invalidAddress": "Invalid address.", + "invalidAddress": "Неверный адрес.", "@invalidAddress": {}, - "serverAddressMustIncludeAScheme": "Server address must include a scheme.", + "serverAddressMustIncludeAScheme": "Адрес сервера должен включать схему.", "@serverAddressMustIncludeAScheme": {}, - "serverAddressMustNotBeEmpty": "Server address must not be empty.", + "serverAddressMustNotBeEmpty": "Адрес сервера не должен быть пустым.", "@serverAddressMustNotBeEmpty": {}, - "signIn": "Sign In", + "signIn": "Войти", "@signIn": {}, - "loginPageSignInTitle": "Sign In", + "loginPageSignInTitle": "Войти", "@loginPageSignInTitle": {}, - "signInToServer": "Sign in to {serverAddress}", + "signInToServer": "Войти в {serverAddress}", "@signInToServer": { "placeholders": { "serverAddress": {} } }, - "connectToPaperless": "Connect to Paperless", + "connectToPaperless": "Подключение к Paperless", "@connectToPaperless": {}, - "username": "Username", + "username": "Имя пользователя", "@username": {}, - "usernameMustNotBeEmpty": "Username must not be empty.", + "usernameMustNotBeEmpty": "Имя пользователя не должно быть пустым.", "@usernameMustNotBeEmpty": {}, - "documentContainsAllOfTheseWords": "Document contains all of these words", + "documentContainsAllOfTheseWords": "Документ содержит все эти слова", "@documentContainsAllOfTheseWords": {}, - "all": "All", + "all": "Все", "@all": {}, - "documentContainsAnyOfTheseWords": "Document contains any of these words", + "documentContainsAnyOfTheseWords": "Документ содержит любое из этих слов", "@documentContainsAnyOfTheseWords": {}, - "any": "Any", + "any": "Любые", "@any": {}, - "learnMatchingAutomatically": "Learn matching automatically", + "learnMatchingAutomatically": "Научиться подбирать автоматически", "@learnMatchingAutomatically": {}, - "auto": "Auto", + "auto": "Авто", "@auto": {}, - "documentContainsThisString": "Document contains this string", + "documentContainsThisString": "Документ содержит эту строку", "@documentContainsThisString": {}, - "exact": "Exact", + "exact": "Точно", "@exact": {}, - "documentContainsAWordSimilarToThisWord": "Document contains a word similar to this word", + "documentContainsAWordSimilarToThisWord": "Документ содержит слово, похожее на это слово", "@documentContainsAWordSimilarToThisWord": {}, - "fuzzy": "Fuzzy", + "fuzzy": "Неточно", "@fuzzy": {}, - "documentMatchesThisRegularExpression": "Document matches this regular expression", + "documentMatchesThisRegularExpression": "Документ соответствует этому регулярному выражению", "@documentMatchesThisRegularExpression": {}, - "regularExpression": "Regular Expression", + "regularExpression": "Регулярное выражение", "@regularExpression": {}, - "anInternetConnectionCouldNotBeEstablished": "An internet connection could not be established.", + "anInternetConnectionCouldNotBeEstablished": "Не удалось установить соединение с интернетом.", "@anInternetConnectionCouldNotBeEstablished": {}, - "done": "Done", + "done": "Готово", "@done": {}, - "next": "Next", + "next": "Следующее", "@next": {}, - "couldNotAccessReceivedFile": "Could not access the received file. Please try to open the app before sharing.", + "couldNotAccessReceivedFile": "Не удалось получить доступ к полученному файлу. Пожалуйста, попробуйте открыть приложение перед тем, как поделиться.", "@couldNotAccessReceivedFile": {}, - "newView": "New View", + "newView": "Новый вид", "@newView": {}, - "createsASavedViewBasedOnTheCurrentFilterCriteria": "Creates a new view based on the current filter criteria.", + "createsASavedViewBasedOnTheCurrentFilterCriteria": "Создает новый вид на основе текущих критериев фильтра.", "@createsASavedViewBasedOnTheCurrentFilterCriteria": {}, - "createViewsToQuicklyFilterYourDocuments": "Create views to quickly filter your documents.", + "createViewsToQuicklyFilterYourDocuments": "Создавайте виды для быстрой фильтрации документов.", "@createViewsToQuicklyFilterYourDocuments": {}, - "nFiltersSet": "{count, plural, zero{{count} filters set} one{{count} filter set} other{{count} filters set}}", + "nFiltersSet": "{count, plural, one{{count} фильтр установлен} few {{count} фильтров установлено} many {{count} фильтров установлено} other{{count} фильтров установлено}}", "@nFiltersSet": { "placeholders": { "count": {} } }, - "showInSidebar": "Show in sidebar", + "showInSidebar": "Показать в боковой панели", "@showInSidebar": {}, - "showOnDashboard": "Show on dashboard", + "showOnDashboard": "Показать в панели управления", "@showOnDashboard": {}, - "views": "Views", + "views": "Виды", "@views": {}, - "clearAll": "Clear all", + "clearAll": "Очистить все", "@clearAll": {}, - "scan": "Scan", + "scan": "Сканировать", "@scan": {}, - "previewScan": "Preview", + "previewScan": "Предпросмотр", "@previewScan": {}, - "scrollToTop": "Scroll to top", + "scrollToTop": "Прокрутить к началу", "@scrollToTop": {}, - "paperlessServerVersion": "Paperless server version", + "paperlessServerVersion": "Версия сервера Paperless", "@paperlessServerVersion": {}, - "darkTheme": "Dark Theme", + "darkTheme": "Темная тема", "@darkTheme": {}, - "lightTheme": "Light Theme", + "lightTheme": "Светлая тема", "@lightTheme": {}, - "systemTheme": "Use system theme", + "systemTheme": "Использовать системную тему", "@systemTheme": {}, - "appearance": "Appearance", + "appearance": "Оформление", "@appearance": {}, - "languageAndVisualAppearance": "Language and visual appearance", + "languageAndVisualAppearance": "Язык и визуальный вид", "@languageAndVisualAppearance": {}, - "applicationSettings": "Application", + "applicationSettings": "Применение", "@applicationSettings": {}, - "colorSchemeHint": "Choose between a classic color scheme inspired by a traditional Paperless green or use the dynamic color scheme based on your system theme.", + "colorSchemeHint": "Выберите между классической цветовой схемой, вдохновленной традиционным зеленым Paperless или используйте динамическую цветовую схему на основе вашей системной темы.", "@colorSchemeHint": {}, - "colorSchemeNotSupportedWarning": "Dynamic theming is only supported for devices running Android 12 and above. Selecting the 'Dynamic' option might not have any effect depending on your OS implementation.", + "colorSchemeNotSupportedWarning": "Динамическая тема поддерживается только для устройств с Android 12 и выше. Выбор параметра \"Динамическая\" может не повлиять на реализацию вашей ОС.", "@colorSchemeNotSupportedWarning": {}, - "colors": "Colors", + "colors": "Цвета", "@colors": {}, - "language": "Language", + "language": "Язык", "@language": {}, - "security": "Security", + "security": "Безопасность", "@security": {}, - "mangeFilesAndStorageSpace": "Manage files and storage space", + "mangeFilesAndStorageSpace": "Управлять файлами и пространством памяти", "@mangeFilesAndStorageSpace": {}, - "storage": "Storage", + "storage": "Хранилище", "@storage": {}, - "dark": "Dark", + "dark": "Темная", "@dark": {}, - "light": "Light", + "light": "Светлая", "@light": {}, - "system": "System", + "system": "Системная", "@system": {}, - "ascending": "Ascending", + "ascending": "Возрастание", "@ascending": {}, - "descending": "Descending", + "descending": "Убыванию", "@descending": {}, - "storagePathDay": "day", + "storagePathDay": "день", "@storagePathDay": {}, - "storagePathMonth": "month", + "storagePathMonth": "месяц", "@storagePathMonth": {}, - "storagePathYear": "year", + "storagePathYear": "год", "@storagePathYear": {}, - "color": "Color", + "color": "Цвет", "@color": {}, - "filterTags": "Filter tags...", + "filterTags": "Фильтр тегов...", "@filterTags": {}, - "inboxTag": "Inbox-Tag", + "inboxTag": "Тег \"Входящие\"", "@inboxTag": {}, - "uploadInferValuesHint": "If you specify values for these fields, your paperless instance will not automatically derive a value. If you want these values to be automatically populated by your server, leave the fields blank.", + "uploadInferValuesHint": "Если вы укажете значения для этих полей, ваш paperless экземпляр не будет автоматически получать значение. Если вы хотите, чтобы эти значения были автоматически заполнены сервером, оставьте поля пустыми.", "@uploadInferValuesHint": {}, - "useTheConfiguredBiometricFactorToAuthenticate": "Use the configured biometric factor to authenticate and unlock your documents.", + "useTheConfiguredBiometricFactorToAuthenticate": "Используйте настроенный биометрический фактор для аутентификации и разблокировки документов.", "@useTheConfiguredBiometricFactorToAuthenticate": {}, - "verifyYourIdentity": "Verify your identity", + "verifyYourIdentity": "Подтвердите вашу личность", "@verifyYourIdentity": {}, - "verifyIdentity": "Verify Identity", + "verifyIdentity": "Подтвердить личность", "@verifyIdentity": {}, - "detailed": "Detailed", + "detailed": "Подробный", "@detailed": {}, - "grid": "Grid", + "grid": "Сетка", "@grid": {}, - "list": "List", + "list": "Список", "@list": {}, - "remove": "Remove", - "removeQueryFromSearchHistory": "Remove query from search history?", - "dynamicColorScheme": "Dynamic", + "remove": "Удалить", + "removeQueryFromSearchHistory": "Удалить запрос из истории поиска?", + "dynamicColorScheme": "Динамическое", "@dynamicColorScheme": {}, - "classicColorScheme": "Classic", + "classicColorScheme": "Классическое", "@classicColorScheme": {}, - "notificationDownloadComplete": "Download complete", + "notificationDownloadComplete": "Загрузка завершена", "@notificationDownloadComplete": { "description": "Notification title when a download has been completed." }, - "notificationDownloadingDocument": "Downloading document", + "notificationDownloadingDocument": "Загружается документ", "@notificationDownloadingDocument": { "description": "Notification title shown when a document download is pending" }, - "archiveSerialNumberUpdated": "Archive Serial Number updated.", + "archiveSerialNumberUpdated": "Архивный серийный номер обновлен.", "@archiveSerialNumberUpdated": { "description": "Message shown when the ASN has been updated." }, - "donateCoffee": "Buy me a coffee", + "donateCoffee": "Купите мне кофе", "@donateCoffee": { "description": "Label displayed in the app drawer" }, - "thisFieldIsRequired": "This field is required!", + "thisFieldIsRequired": "Требуется заполнить это поле!", "@thisFieldIsRequired": { "description": "Message shown below the form field when a required field has not been filled out." }, - "confirm": "Confirm", - "confirmAction": "Confirm action", + "confirm": "Подтвердить", + "confirmAction": "Подтвердить действие", "@confirmAction": { "description": "Typically used as a title to confirm a previously selected action" }, - "areYouSureYouWantToContinue": "Are you sure you want to continue?", - "bulkEditTagsAddMessage": "{count, plural, one{This operation will add the tags {tags} to the selected document.} other{This operation will add the tags {tags} to {count} selected documents.}}", + "areYouSureYouWantToContinue": "Вы уверены, что хотите продолжить?", + "bulkEditTagsAddMessage": "{count, plural, one{Эта операция добавит теги {tags} в выбранный документ.} other{Эта операция добавит теги {tags} в {count} выбранных документов.}}", "@bulkEditTagsAddMessage": { "description": "Message of the confirmation dialog when bulk adding tags." }, - "bulkEditTagsRemoveMessage": "{count, plural, one{This operation will remove the tags {tags} from the selected document.} other{This operation will remove the tags {tags} from {count} selected documents.}}", + "bulkEditTagsRemoveMessage": "{count, plural, one{Эта операция удалит теги {tags} из выбранного документа.} other{Эта операция удалит теги {tags} из {count} выбранных документов.}}", "@bulkEditTagsRemoveMessage": { "description": "Message of the confirmation dialog when bulk removing tags." }, - "bulkEditTagsModifyMessage": "{count, plural, one{This operation will add the tags {addTags} and remove the tags {removeTags} from the selected document.} other{This operation will add the tags {addTags} and remove the tags {removeTags} from {count} selected documents.}}", + "bulkEditTagsModifyMessage": "{count, plural, one{Данная операция добавит теги {addTags} и удалит теги {removeTags} из выбранного документа.} other{Данная операция добавит теги {addTags} и удалит теги {removeTags} из {count} выбранных документов.}}", "@bulkEditTagsModifyMessage": { "description": "Message of the confirmation dialog when both adding and removing tags." }, - "bulkEditCorrespondentAssignMessage": "{count, plural, one{This operation will assign the correspondent {correspondent} to the selected document.} other{This operation will assign the correspondent {correspondent} to {count} selected documents.}}", - "bulkEditDocumentTypeAssignMessage": "{count, plural, one{This operation will assign the document type {docType} to the selected document.} other{This operation will assign the documentType {docType} to {count} selected documents.}}", - "bulkEditStoragePathAssignMessage": "{count, plural, one{This operation will assign the storage path {path} to the selected document.} other{This operation will assign the storage path {path} to {count} selected documents.}}", - "bulkEditCorrespondentRemoveMessage": "{count, plural, one{This operation will remove the correspondent from the selected document.} other{This operation will remove the correspondent from {count} selected documents.}}", - "bulkEditDocumentTypeRemoveMessage": "{count, plural, one{This operation will remove the document type from the selected document.} other{This operation will remove the document type from {count} selected documents.}}", - "bulkEditStoragePathRemoveMessage": "{count, plural, one{This operation will remove the storage path from the selected document.} other{This operation will remove the storage path from {count} selected documents.}}", - "anyTag": "Any", + "bulkEditCorrespondentAssignMessage": "{count, plural, one{Эта операция присвоит корреспондента {correspondent} выбранному документу.} other{Эта операция присвоит корреспондента {correspondent} {count} выбранных документов.}}", + "bulkEditDocumentTypeAssignMessage": "{count, plural, one{Эта операция присвоит тип документа {docType} выбранному документу.} other{Эта операция присвоит тип документа {docType} {count} выбранным документам.}}", + "bulkEditStoragePathAssignMessage": "{count, plural, one{Эта операция назначит путь хранения {path} выбранному документу.} other{Эта операция назначит путь хранения {path} {count} выбранным документам.}}", + "bulkEditCorrespondentRemoveMessage": "{count, plural, one{Эта операция удалит корреспондента из выбранного документа.} other{Эта операция удалит корреспондента из {count} выбранных документов.}}", + "bulkEditDocumentTypeRemoveMessage": "{count, plural, one{Данная операция удалит тип документа из выбранного документа.} other{Данная операция удалит тип документа из {count} выбранных документов.}}", + "bulkEditStoragePathRemoveMessage": "{count, plural, one{Эта операция удалит путь хранения из выбранного документа.} other{Эта операция удалит путь хранения из {count} выбранных документов.}}", + "anyTag": "Любые", "@anyTag": { "description": "Label shown when any tag should be filtered" }, - "allTags": "All", + "allTags": "Все", "@allTags": { "description": "Label shown when a document has to be assigned to all selected tags" }, - "switchingAccountsPleaseWait": "Switching accounts. Please wait...", + "switchingAccountsPleaseWait": "Смена аккаунтов. Подождите...", "@switchingAccountsPleaseWait": { "description": "Message shown while switching accounts is in progress." }, - "testConnection": "Test connection", + "testConnection": "Проверить подключение", "@testConnection": { "description": "Button label shown on login page. Allows user to test whether the server is reachable or not." }, - "accounts": "Accounts", + "accounts": "Аккаунты", "@accounts": { "description": "Title of the account management dialog" }, - "addAccount": "Add account", + "addAccount": "Добавить аккаунт", "@addAccount": { "description": "Label of add account action" }, - "switchAccount": "Switch", + "switchAccount": "Сменить", "@switchAccount": { "description": "Label for switch account action" }, - "logout": "Logout", + "logout": "Выйти", "@logout": { "description": "Generic Logout label" }, - "switchAccountTitle": "Switch account", + "switchAccountTitle": "Сменить аккаунт", "@switchAccountTitle": { "description": "Title of the dialog shown after adding an account, asking the user whether to switch to the newly added account or not." }, - "switchToNewAccount": "Do you want to switch to the new account? You can switch back at any time.", + "switchToNewAccount": "Вы хотите переключиться на новый аккаунт? Вы можете переключиться обратно в любое время.", "@switchToNewAccount": { "description": "Content of the dialog shown after adding an account, asking the user whether to switch to the newly added account or not." }, - "sourceCode": "Source Code", - "findTheSourceCodeOn": "Find the source code on", + "sourceCode": "Исходный код", + "findTheSourceCodeOn": "Найти исходный код на", "@findTheSourceCodeOn": { "description": "Text before link to Paperless Mobile GitHub" }, - "rememberDecision": "Remember my decision", - "defaultDownloadFileType": "Default Download File Type", + "rememberDecision": "Запомните мой выбор", + "defaultDownloadFileType": "Тип загружаемого файла по умолчанию", "@defaultDownloadFileType": { "description": "Label indicating the default filetype to download (one of archived, original and always ask)" }, - "defaultShareFileType": "Default Share File Type", + "defaultShareFileType": "Тип файла обмена по умолчанию", "@defaultShareFileType": { "description": "Label indicating the default filetype to share (one of archived, original and always ask)" }, - "alwaysAsk": "Always ask", + "alwaysAsk": "Всегда спрашивать", "@alwaysAsk": { "description": "Option to choose when the app should always ask the user which filetype to use" }, - "disableMatching": "Do not tag documents automatically", + "disableMatching": "Не отмечать документы автоматически", "@disableMatching": { "description": "One of the options for automatic tagging of documents" }, - "none": "None", + "none": "Ничего", "@none": { "description": "One of available enum values of matching algorithm for tags" }, - "logInToExistingAccount": "Log in to existing account", + "logInToExistingAccount": "Войти в существующий аккаунт", "@logInToExistingAccount": { "description": "Title shown on login page if at least one user is already known to the app." }, - "print": "Print", + "print": "Распечатать", "@print": { "description": "Tooltip for print button" }, - "managePermissions": "Manage permissions", + "managePermissions": "Управление разрешениями", "@managePermissions": { "description": "Button which leads user to manage permissions page" }, - "errorRetrievingServerVersion": "An error occurred trying to resolve the server version.", + "errorRetrievingServerVersion": "Произошла ошибка при попытке определить версию сервера.", "@errorRetrievingServerVersion": { "description": "Message shown at the bottom of the settings page when the remote server version could not be resolved." }, - "resolvingServerVersion": "Resolving server version...", + "resolvingServerVersion": "Определение версии сервера...", "@resolvingServerVersion": { "description": "Message shown while the app is loading the remote server version." }, - "goToLogin": "Go to login", + "goToLogin": "Перейти ко входу", "@goToLogin": { "description": "Label of the button shown on the login page to skip logging in to existing accounts and navigate user to login page" }, - "export": "Export", + "export": "Экспорт", "@export": { "description": "Label for button that exports scanned images to pdf (before upload)" }, - "invalidFilenameCharacter": "Invalid character(s) found in filename: {characters}", + "invalidFilenameCharacter": "В имени файла обнаружены недопустимые символы: {characters}", "@invalidFilenameCharacter": { "description": "For validating filename in export dialogue" }, - "exportScansToPdf": "Export scans to PDF", + "exportScansToPdf": "Экспортировать сканирования в PDF", "@exportScansToPdf": { "description": "title of the alert dialog when exporting scans to pdf" }, - "allScansWillBeMerged": "All scans will be merged into a single PDF file.", - "behavior": "Behavior", + "allScansWillBeMerged": "Все сканированные файлы будут объединены в один PDF-файл.", + "behavior": "Поведение", "@behavior": { "description": "Title of the settings concerning app beahvior" }, - "theme": "Theme", + "theme": "Тема", "@theme": { "description": "Title of the theme mode setting" }, - "clearCache": "Clear cache", + "clearCache": "Очистить кэш", "@clearCache": { "description": "Title of the clear cache setting" }, - "freeBytes": "Free {byteString}", + "freeBytes": "Свободно {byteString}", "@freeBytes": { "description": "Text shown for clear storage settings" }, - "calculatingDots": "Calculating...", + "calculatingDots": "Расчет...", "@calculatingDots": { "description": "Text shown when the byte size is still being calculated" }, - "freedDiskSpace": "Successfully freed {bytes} of disk space.", + "freedDiskSpace": "{bytes} успешно освобождено на диске.", "@freedDiskSpace": { "description": "Message shown after clearing storage" }, - "uploadScansAsPdf": "Upload scans as PDF", + "uploadScansAsPdf": "Загрузить сканирование в PDF", "@uploadScansAsPdf": { "description": "Title of the setting which toggles whether scans are always uploaded as pdf" }, - "convertSinglePageScanToPdf": "Always convert single page scans to PDF before uploading", + "convertSinglePageScanToPdf": "Всегда конвертировать одну страницу в PDF перед загрузкой", "@convertSinglePageScanToPdf": { "description": "description of the upload scans as pdf setting" }, - "loginRequiredPermissionsHint": "Using Paperless Mobile requires a minimum set of user permissions since paperless-ngx 1.14.0 and higher. Therefore, please make sure that the user to be logged in has the permission to view other users (User → View) and the settings (UISettings → View). If you do not have these permissions, please contact an administrator of your paperless-ngx server.", + "loginRequiredPermissionsHint": "Использование Paperless Mobile требует минимального набора разрешений пользователя, начиная с версии paperless-ngx 1.14.0 и выше. Поэтому убедитесь, что у пользователя, который будет входить в систему, есть права на просмотр других пользователей (Пользователь → Вид) и настроек (Настройки пользовательского интерфейса → Вид). Если эти права отсутствуют, обратитесь к администратору сервера paperless-ngx.", "@loginRequiredPermissionsHint": { "description": "Hint shown on the login page informing the user of the required permissions to use the app." }, - "missingPermissions": "You do not have the necessary permissions to perform this action.", + "missingPermissions": "У вас нет необходимых разрешений для выполнения этого действия.", "@missingPermissions": { "description": "Message shown in a snackbar when a user without the reequired permissions performs an action." }, - "editView": "Edit View", + "editView": "Редактировать вид", "@editView": { "description": "Title of the edit saved view page" }, - "donate": "Donate", + "donate": "Пожертвовать", "@donate": { "description": "Label of the in-app donate button" }, - "donationDialogContent": "Thank you for considering to support this app! Due to both Google's and Apple's Payment Policies, no links leading to donations may be displayed in-app. Not even linking to the project's repository page appears to be allowed in this context. Therefore, maybe have a look at the 'Donations' section in the project's README. Your support is much appreciated and keeps the development of this app alive. Thanks!", + "donationDialogContent": "Спасибо, что решили поддержать это приложение! В соответствии с политикой платежей Google и Apple, ссылки, ведущие на пожертвования, не могут отображаться в приложении. Даже ссылки на страницу репозитория проекта, по-видимому, не разрешены в данном контексте. Поэтому, возможно, стоит обратить внимание на раздел \"Пожертвования\" в README проекта. Мы очень ценим вашу поддержку и поддерживаем развитие этого приложения. Спасибо!", "@donationDialogContent": { "description": "Text displayed in the donation dialog" }, - "noDocumentsFound": "No documents found.", + "noDocumentsFound": "Документы не найдены.", "@noDocumentsFound": { "description": "Message shown when no documents were found." }, - "couldNotDeleteCorrespondent": "Could not delete correspondent, please try again.", + "couldNotDeleteCorrespondent": "Не удалось удалить корреспондента, попробуйте еще раз.", "@couldNotDeleteCorrespondent": { "description": "Message shown in snackbar when a correspondent could not be deleted." }, - "couldNotDeleteDocumentType": "Could not delete document type, please try again.", + "couldNotDeleteDocumentType": "Не удалось удалить тип документа, попробуйте еще раз.", "@couldNotDeleteDocumentType": { "description": "Message shown when a document type could not be deleted" }, - "couldNotDeleteTag": "Could not delete tag, please try again.", + "couldNotDeleteTag": "Не удалось удалить тег, попробуйте еще раз.", "@couldNotDeleteTag": { "description": "Message shown when a tag could not be deleted" }, - "couldNotDeleteStoragePath": "Could not delete storage path, please try again.", + "couldNotDeleteStoragePath": "Не удалось удалить путь к хранилищу, попробуйте еще раз.", "@couldNotDeleteStoragePath": { "description": "Message shown when a storage path could not be deleted" }, - "couldNotUpdateCorrespondent": "Could not update correspondent, please try again.", + "couldNotUpdateCorrespondent": "Не удалось обновить корреспондента, попробуйте еще раз.", "@couldNotUpdateCorrespondent": { "description": "Message shown when a correspondent could not be updated" }, - "couldNotUpdateDocumentType": "Could not update document type, please try again.", + "couldNotUpdateDocumentType": "Не удалось обновить тип документа, попробуйте еще раз.", "@couldNotUpdateDocumentType": { "description": "Message shown when a document type could not be updated" }, - "couldNotUpdateTag": "Could not update tag, please try again.", + "couldNotUpdateTag": "Не удалось обновить тег, попробуйте еще раз.", "@couldNotUpdateTag": { "description": "Message shown when a tag could not be updated" }, - "couldNotLoadServerInformation": "Could not load server information.", + "couldNotLoadServerInformation": "Не удалось загрузить информацию о сервере.", "@couldNotLoadServerInformation": { "description": "Message shown when the server information could not be loaded" }, - "couldNotLoadStatistics": "Could not load server statistics.", + "couldNotLoadStatistics": "Не удалось загрузить статистику сервера.", "@couldNotLoadStatistics": { "description": "Message shown when the server statistics could not be loaded" }, - "couldNotLoadUISettings": "Could not load UI settings.", + "couldNotLoadUISettings": "Не удалось загрузить настройки пользовательского интерфейса.", "@couldNotLoadUISettings": { "description": "Message shown when the UI settings could not be loaded" }, - "couldNotLoadTasks": "Could not load tasks.", + "couldNotLoadTasks": "Не удалось загрузить задания.", "@couldNotLoadTasks": { "description": "Message shown when the tasks (e.g. document consumed) could not be loaded" }, - "userNotFound": "User could not be found.", + "userNotFound": "Не удалось найти пользователя.", "@userNotFound": { "description": "Message shown when the specified user (e.g. by id) could not be found" }, - "couldNotUpdateSavedView": "Could not update saved view, please try again.", + "couldNotUpdateSavedView": "Не удалось обновить сохраненный вид, попробуйте еще раз.", "@couldNotUpdateSavedView": { "description": "Message shown when a saved view could not be updated" }, - "couldNotUpdateStoragePath": "Could not update storage path, please try again.", - "savedViewSuccessfullyUpdated": "Saved view successfully updated.", + "couldNotUpdateStoragePath": "Не удалось обновить путь к хранилищу, попробуйте еще раз.", + "savedViewSuccessfullyUpdated": "Сохраненный вид успешно обновлен.", "@savedViewSuccessfullyUpdated": { "description": "Message shown when a saved view was successfully updated." }, - "discardChanges": "Discard changes?", + "discardChanges": "Не сохранять изменения?", "@discardChanges": { "description": "Title of the alert dialog shown when a user tries to close a view with unsaved changes." }, - "savedViewChangedDialogContent": "The filter conditions of the active view have changed. By resetting the filter, these changes will be lost. Do you still wish to continue?", + "savedViewChangedDialogContent": "Условия фильтра активного вида изменились. Сброс фильтра будет утерян. Вы все равно хотите продолжить?", "@savedViewChangedDialogContent": { "description": "Content of the alert dialog shown when all of the following applies:\r\n* User has saved view selected\r\n* User has performed changes to the current document filter\r\n* User now tries to reset this filter without having saved the changes to the view." }, - "createFromCurrentFilter": "Create from current filter", + "createFromCurrentFilter": "Создать из текущего фильтра", "@createFromCurrentFilter": { "description": "Tooltip of the \"New saved view\" button" }, - "home": "Home", + "home": "Домашняя страница", "@home": { "description": "Label of the \"Home\" route" }, - "welcomeUser": "Welcome, {name}!", + "welcomeUser": "Добро пожаловать, {name}!", "@welcomeUser": { "description": "Top message shown on the home page" }, - "noSavedViewOnHomepageHint": "Configure a saved view to be displayed on your home page and it will show up here.", + "noSavedViewOnHomepageHint": "Настройте сохраненный вид для отображения на вашей домашней страница и он будет отображаться здесь.", "@noSavedViewOnHomepageHint": { "description": "Message shown when there is no saved view to display on the home page." }, - "statistics": "Statistics", - "documentsInInbox": "Documents in inbox", - "totalDocuments": "Total documents", - "totalCharacters": "Total characters", - "showAll": "Show all", + "statistics": "Статистика", + "documentsInInbox": "Документы во входящих", + "totalDocuments": "Всего документов", + "totalCharacters": "Всего символов", + "showAll": "Показать все", "@showAll": { "description": "Button label shown on a saved view preview to open this view in the documents page" }, - "userAlreadyExists": "This user already exists.", + "userAlreadyExists": "Этот пользователь уже существует.", "@userAlreadyExists": { "description": "Error message shown when the user tries to add an already existing account." - } + }, + "youDidNotSaveAnyViewsYet": "Вы еще не сохранили ни одного вида, создайте его и он будет показан здесь.", + "@youDidNotSaveAnyViewsYet": { + "description": "Message shown when there are no saved views yet." + }, + "tryAgain": "Try again", + "discardFile": "Discard file?", + "discard": "Discard" } \ No newline at end of file diff --git a/lib/l10n/intl_tr.arb b/lib/l10n/intl_tr.arb index f7795c7..7fa97a4 100644 --- a/lib/l10n/intl_tr.arb +++ b/lib/l10n/intl_tr.arb @@ -973,5 +973,12 @@ "userAlreadyExists": "This user already exists.", "@userAlreadyExists": { "description": "Error message shown when the user tries to add an already existing account." - } + }, + "youDidNotSaveAnyViewsYet": "You did not save any views yet, create one and it will be shown here.", + "@youDidNotSaveAnyViewsYet": { + "description": "Message shown when there are no saved views yet." + }, + "tryAgain": "Try again", + "discardFile": "Discard file?", + "discard": "Discard" } \ No newline at end of file diff --git a/lib/main.dart b/lib/main.dart index 416bb00..d2a51c1 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -28,15 +28,14 @@ import 'package:paperless_mobile/core/exception/server_message_exception.dart'; import 'package:paperless_mobile/core/factory/paperless_api_factory.dart'; import 'package:paperless_mobile/core/factory/paperless_api_factory_impl.dart'; import 'package:paperless_mobile/core/interceptor/language_header.interceptor.dart'; -import 'package:paperless_mobile/core/model/info_message_exception.dart'; import 'package:paperless_mobile/core/notifier/document_changed_notifier.dart'; import 'package:paperless_mobile/core/security/session_manager.dart'; import 'package:paperless_mobile/core/service/connectivity_status_service.dart'; -import 'package:paperless_mobile/core/translation/error_code_localization_mapper.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'; import 'package:paperless_mobile/features/settings/view/widgets/global_settings_builder.dart'; +import 'package:paperless_mobile/features/sharing/model/share_intent_queue.dart'; import 'package:paperless_mobile/generated/l10n/app_localizations.dart'; import 'package:paperless_mobile/routes/navigation_keys.dart'; import 'package:paperless_mobile/routes/typed/branches/documents_route.dart'; @@ -45,6 +44,7 @@ import 'package:paperless_mobile/routes/typed/branches/labels_route.dart'; import 'package:paperless_mobile/routes/typed/branches/landing_route.dart'; import 'package:paperless_mobile/routes/typed/branches/saved_views_route.dart'; import 'package:paperless_mobile/routes/typed/branches/scanner_route.dart'; +import 'package:paperless_mobile/routes/typed/branches/upload_queue_route.dart'; import 'package:paperless_mobile/routes/typed/shells/provider_shell_route.dart'; import 'package:paperless_mobile/routes/typed/shells/scaffold_shell_route.dart'; import 'package:paperless_mobile/routes/typed/top_level/login_route.dart'; @@ -84,17 +84,17 @@ Future _initHive() async { void main() async { runZonedGuarded(() async { Paint.enableDithering = true; - if (kDebugMode) { - // URL: http://localhost:3131 - // Login: admin:test - await LocalMockApiServer( - // RandomDelayGenerator( - // const Duration(milliseconds: 100), - // const Duration(milliseconds: 800), - // ), - ) - .start(); - } + // if (kDebugMode) { + // // URL: http://localhost:3131 + // // Login: admin:test + // await LocalMockApiServer( + // // RandomDelayGenerator( + // // const Duration(milliseconds: 100), + // // const Duration(milliseconds: 800), + // // ), + // ) + // .start(); + // } await _initHive(); final widgetsBinding = WidgetsFlutterBinding.ensureInitialized(); final globalSettingsBox = @@ -154,7 +154,7 @@ void main() async { connectivityStatusService, ); await authenticationCubit.restoreSessionState(); - + await ShareIntentQueue.instance.initialize(); runApp( MultiProvider( providers: [ @@ -240,6 +240,7 @@ class _GoRouterShellState extends State { routes: [ $settingsRoute, $savedViewsRoute, + $uploadQueueRoute, StatefulShellRoute( navigatorContainerBuilder: (context, navigationShell, children) { return children[navigationShell.currentIndex]; @@ -281,7 +282,6 @@ class _GoRouterShellState extends State { case UnauthenticatedState(): const LoginRoute().go(context); break; - case RequiresLocalAuthenticationState(): const VerifyIdentityRoute().go(context); break; @@ -292,6 +292,8 @@ class _GoRouterShellState extends State { const LandingRoute().go(context); break; case AuthenticationErrorState(): + const LoginRoute().go(context); + break; } }, child: GlobalSettingsBuilder( diff --git a/lib/routes/routes.dart b/lib/routes/routes.dart index 0dc9dd4..ed91b82 100644 --- a/lib/routes/routes.dart +++ b/lib/routes/routes.dart @@ -20,4 +20,5 @@ class R { static const settings = "settings"; static const linkedDocuments = "linkedDocuments"; static const bulkEditDocuments = "bulkEditDocuments"; + static const uploadQueue = "uploadQueue"; } diff --git a/lib/routes/typed/branches/scanner_route.dart b/lib/routes/typed/branches/scanner_route.dart index 4bfb01d..c7ce700 100644 --- a/lib/routes/typed/branches/scanner_route.dart +++ b/lib/routes/typed/branches/scanner_route.dart @@ -1,3 +1,5 @@ +import 'dart:async'; + import 'package:flutter/foundation.dart'; import 'package:flutter/widgets.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; @@ -51,7 +53,7 @@ class ScannerRoute extends GoRouteData { class DocumentUploadRoute extends GoRouteData { static final GlobalKey $parentNavigatorKey = rootNavigatorKey; - final Uint8List $extra; + final FutureOr $extra; final String? title; final String? filename; final String? fileExtension; diff --git a/lib/routes/typed/branches/upload_queue_route.dart b/lib/routes/typed/branches/upload_queue_route.dart new file mode 100644 index 0000000..fa3327a --- /dev/null +++ b/lib/routes/typed/branches/upload_queue_route.dart @@ -0,0 +1,20 @@ +import 'package:flutter/material.dart'; +import 'package:go_router/go_router.dart'; +import 'package:paperless_mobile/features/sharing/view/consumption_queue_view.dart'; +import 'package:paperless_mobile/routes/navigation_keys.dart'; +import 'package:paperless_mobile/routes/routes.dart'; + +part 'upload_queue_route.g.dart'; + +@TypedGoRoute( + path: "/upload-queue", + name: R.uploadQueue, +) +class UploadQueueRoute extends GoRouteData { + static final GlobalKey $parentNavigatorKey = rootNavigatorKey; + + @override + Widget build(BuildContext context, GoRouterState state) { + return const ConsumptionQueueView(); + } +} diff --git a/lib/routes/typed/shells/provider_shell_route.dart b/lib/routes/typed/shells/provider_shell_route.dart index 99f29a4..e100479 100644 --- a/lib/routes/typed/shells/provider_shell_route.dart +++ b/lib/routes/typed/shells/provider_shell_route.dart @@ -1,5 +1,4 @@ import 'package:flutter/widgets.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_config.dart'; @@ -7,7 +6,12 @@ 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'; import 'package:paperless_mobile/features/home/view/home_shell_widget.dart'; +import 'package:paperless_mobile/features/sharing/cubit/receive_share_cubit.dart'; +import 'package:paperless_mobile/features/sharing/view/widgets/upload_queue_shell.dart'; import 'package:paperless_mobile/routes/navigation_keys.dart'; +import 'package:provider/provider.dart'; + +/// Key used to access //part 'provider_shell_route.g.dart'; //TODO: Wait for https://github.com/flutter/flutter/issues/127371 to be merged @@ -66,7 +70,11 @@ class ProviderShellRoute extends ShellRouteData { localUserId: authenticatedUser.id, paperlessApiVersion: authenticatedUser.apiVersion, paperlessProviderFactory: apiFactory, - child: navigator, + child: ChangeNotifierProvider( + create: (context) => ConsumptionChangeNotifier() + ..loadFromConsumptionDirectory(userId: currentUserId), + child: UploadQueueShell(child: navigator), + ), ); } } diff --git a/packages/paperless_api/lib/src/models/task/task.dart b/packages/paperless_api/lib/src/models/task/task.dart index 59b863f..43be02b 100644 --- a/packages/paperless_api/lib/src/models/task/task.dart +++ b/packages/paperless_api/lib/src/models/task/task.dart @@ -21,6 +21,8 @@ class Task extends Equatable { @JsonKey(fromJson: tryParseNullable) final int? relatedDocument; + bool get isSuccess => status == TaskStatus.success; + const Task({ required this.id, this.taskId, diff --git a/pubspec.lock b/pubspec.lock index 986c05e..212a30b 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -479,6 +479,14 @@ packages: description: flutter source: sdk version: "0.0.0" + flutter_animate: + dependency: "direct main" + description: + name: flutter_animate + sha256: "62f346340a96192070e31e3f2a1bd30a28530f1fe8be978821e06cd56b74d6d2" + url: "https://pub.dev" + source: hosted + version: "4.2.0+1" flutter_bloc: dependency: "direct main" description: @@ -1664,6 +1672,14 @@ packages: url: "https://pub.dev" source: hosted version: "1.0.1" + transparent_image: + dependency: "direct main" + description: + name: transparent_image + sha256: e8991d955a2094e197ca24c645efec2faf4285772a4746126ca12875e54ca02f + url: "https://pub.dev" + source: hosted + version: "2.0.1" typed_data: dependency: transitive description: diff --git a/pubspec.yaml b/pubspec.yaml index 6e542d9..a77281d 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -94,6 +94,8 @@ dependencies: fl_chart: ^0.63.0 palette_generator: ^0.3.3+2 defer_pointer: ^0.0.2 + transparent_image: ^2.0.1 + flutter_animate: ^4.2.0+1 dependency_overrides: intl: ^0.18.1