Merge branch 'feature/go_router_migration' into development

This commit is contained in:
Anton Stubenbord
2023-10-06 01:17:25 +02:00
213 changed files with 10336 additions and 6630 deletions
+2 -1
View File
@@ -3,7 +3,8 @@
<application android:label="Paperless Mobile" <application android:label="Paperless Mobile"
android:name="${applicationName}" android:name="${applicationName}"
android:icon="@mipmap/ic_launcher" android:icon="@mipmap/ic_launcher"
android:requestLegacyExternalStorage="true"> android:requestLegacyExternalStorage="true"
>
<activity <activity
android:name=".MainActivity" android:name=".MainActivity"
android:exported="true" android:exported="true"
+1
View File
@@ -0,0 +1 @@
<svg width="98" height="96" xmlns="http://www.w3.org/2000/svg"><path fill-rule="evenodd" clip-rule="evenodd" d="M48.854 0C21.839 0 0 22 0 49.217c0 21.756 13.993 40.172 33.405 46.69 2.427.49 3.316-1.059 3.316-2.362 0-1.141-.08-5.052-.08-9.127-13.59 2.934-16.42-5.867-16.42-5.867-2.184-5.704-5.42-7.17-5.42-7.17-4.448-3.015.324-3.015.324-3.015 4.934.326 7.523 5.052 7.523 5.052 4.367 7.496 11.404 5.378 14.235 4.074.404-3.178 1.699-5.378 3.074-6.6-10.839-1.141-22.243-5.378-22.243-24.283 0-5.378 1.94-9.778 5.014-13.2-.485-1.222-2.184-6.275.486-13.038 0 0 4.125-1.304 13.426 5.052a46.97 46.97 0 0 1 12.214-1.63c4.125 0 8.33.571 12.213 1.63 9.302-6.356 13.427-5.052 13.427-5.052 2.67 6.763.97 11.816.485 13.038 3.155 3.422 5.015 7.822 5.015 13.2 0 18.905-11.404 23.06-22.324 24.283 1.78 1.548 3.316 4.481 3.316 9.126 0 6.6-.08 11.897-.08 13.526 0 1.304.89 2.853 3.316 2.364 19.412-6.52 33.405-24.935 33.405-46.691C97.707 22 75.788 0 48.854 0z" fill="#24292f"/></svg>

After

Width:  |  Height:  |  Size: 963 B

+1
View File
@@ -1,3 +1,4 @@
project_id: "568557"
files: [ files: [
{ {
"source" : "/lib/l10n/intl_en.arb", "source" : "/lib/l10n/intl_en.arb",
+39 -34
View File
@@ -35,7 +35,7 @@ PODS:
- DKPhotoGallery/Resource (0.0.17): - DKPhotoGallery/Resource (0.0.17):
- SDWebImage - SDWebImage
- SwiftyGif - SwiftyGif
- edge_detection (1.1.1): - edge_detection (1.1.2):
- Flutter - Flutter
- WeScan - WeScan
- file_picker (0.0.1): - file_picker (0.0.1):
@@ -48,14 +48,16 @@ PODS:
- Flutter - Flutter
- flutter_native_splash (0.0.1): - flutter_native_splash (0.0.1):
- Flutter - Flutter
- flutter_pdfview (1.0.2):
- Flutter
- flutter_secure_storage (6.0.0):
- Flutter
- fluttertoast (0.0.2): - fluttertoast (0.0.2):
- Flutter - Flutter
- Toast - Toast
- FMDB (2.7.5): - FMDB (2.7.5):
- FMDB/standard (= 2.7.5) - FMDB/standard (= 2.7.5)
- FMDB/standard (2.7.5) - FMDB/standard (2.7.5)
- in_app_review (0.2.0):
- Flutter
- integration_test (0.0.1): - integration_test (0.0.1):
- Flutter - Flutter
- local_auth_ios (0.0.1): - local_auth_ios (0.0.1):
@@ -67,9 +69,9 @@ PODS:
- path_provider_foundation (0.0.1): - path_provider_foundation (0.0.1):
- Flutter - Flutter
- FlutterMacOS - FlutterMacOS
- pdfx (1.0.0): - permission_handler_apple (9.1.1):
- Flutter - Flutter
- permission_handler_apple (9.0.4): - printing (1.0.0):
- Flutter - Flutter
- ReachabilitySwift (5.0.0) - ReachabilitySwift (5.0.0)
- receive_sharing_intent (0.0.1): - receive_sharing_intent (0.0.1):
@@ -79,16 +81,15 @@ PODS:
- SDWebImage/Core (5.13.5) - SDWebImage/Core (5.13.5)
- share_plus (0.0.1): - share_plus (0.0.1):
- Flutter - Flutter
- shared_preferences_foundation (0.0.1): - sqflite (0.0.3):
- Flutter
- FlutterMacOS
- sqflite (0.0.2):
- Flutter - Flutter
- FMDB (>= 2.7.5) - FMDB (>= 2.7.5)
- SwiftyGif (5.4.3) - SwiftyGif (5.4.3)
- Toast (4.0.0) - Toast (4.0.0)
- url_launcher_ios (0.0.1): - url_launcher_ios (0.0.1):
- Flutter - Flutter
- webview_flutter_wkwebview (0.0.1):
- Flutter
- WeScan (1.7.0) - WeScan (1.7.0)
DEPENDENCIES: DEPENDENCIES:
@@ -100,20 +101,21 @@ DEPENDENCIES:
- flutter_keyboard_visibility (from `.symlinks/plugins/flutter_keyboard_visibility/ios`) - flutter_keyboard_visibility (from `.symlinks/plugins/flutter_keyboard_visibility/ios`)
- flutter_local_notifications (from `.symlinks/plugins/flutter_local_notifications/ios`) - flutter_local_notifications (from `.symlinks/plugins/flutter_local_notifications/ios`)
- flutter_native_splash (from `.symlinks/plugins/flutter_native_splash/ios`) - flutter_native_splash (from `.symlinks/plugins/flutter_native_splash/ios`)
- flutter_pdfview (from `.symlinks/plugins/flutter_pdfview/ios`)
- flutter_secure_storage (from `.symlinks/plugins/flutter_secure_storage/ios`)
- fluttertoast (from `.symlinks/plugins/fluttertoast/ios`) - fluttertoast (from `.symlinks/plugins/fluttertoast/ios`)
- in_app_review (from `.symlinks/plugins/in_app_review/ios`)
- integration_test (from `.symlinks/plugins/integration_test/ios`) - integration_test (from `.symlinks/plugins/integration_test/ios`)
- local_auth_ios (from `.symlinks/plugins/local_auth_ios/ios`) - local_auth_ios (from `.symlinks/plugins/local_auth_ios/ios`)
- open_filex (from `.symlinks/plugins/open_filex/ios`) - open_filex (from `.symlinks/plugins/open_filex/ios`)
- package_info_plus (from `.symlinks/plugins/package_info_plus/ios`) - package_info_plus (from `.symlinks/plugins/package_info_plus/ios`)
- path_provider_foundation (from `.symlinks/plugins/path_provider_foundation/ios`) - path_provider_foundation (from `.symlinks/plugins/path_provider_foundation/darwin`)
- pdfx (from `.symlinks/plugins/pdfx/ios`)
- permission_handler_apple (from `.symlinks/plugins/permission_handler_apple/ios`) - permission_handler_apple (from `.symlinks/plugins/permission_handler_apple/ios`)
- printing (from `.symlinks/plugins/printing/ios`)
- receive_sharing_intent (from `.symlinks/plugins/receive_sharing_intent/ios`) - receive_sharing_intent (from `.symlinks/plugins/receive_sharing_intent/ios`)
- share_plus (from `.symlinks/plugins/share_plus/ios`) - share_plus (from `.symlinks/plugins/share_plus/ios`)
- shared_preferences_foundation (from `.symlinks/plugins/shared_preferences_foundation/ios`)
- sqflite (from `.symlinks/plugins/sqflite/ios`) - sqflite (from `.symlinks/plugins/sqflite/ios`)
- url_launcher_ios (from `.symlinks/plugins/url_launcher_ios/ios`) - url_launcher_ios (from `.symlinks/plugins/url_launcher_ios/ios`)
- webview_flutter_wkwebview (from `.symlinks/plugins/webview_flutter_wkwebview/ios`)
SPEC REPOS: SPEC REPOS:
trunk: trunk:
@@ -143,10 +145,12 @@ EXTERNAL SOURCES:
:path: ".symlinks/plugins/flutter_local_notifications/ios" :path: ".symlinks/plugins/flutter_local_notifications/ios"
flutter_native_splash: flutter_native_splash:
:path: ".symlinks/plugins/flutter_native_splash/ios" :path: ".symlinks/plugins/flutter_native_splash/ios"
flutter_pdfview:
:path: ".symlinks/plugins/flutter_pdfview/ios"
flutter_secure_storage:
:path: ".symlinks/plugins/flutter_secure_storage/ios"
fluttertoast: fluttertoast:
:path: ".symlinks/plugins/fluttertoast/ios" :path: ".symlinks/plugins/fluttertoast/ios"
in_app_review:
:path: ".symlinks/plugins/in_app_review/ios"
integration_test: integration_test:
:path: ".symlinks/plugins/integration_test/ios" :path: ".symlinks/plugins/integration_test/ios"
local_auth_ios: local_auth_ios:
@@ -156,54 +160,55 @@ EXTERNAL SOURCES:
package_info_plus: package_info_plus:
:path: ".symlinks/plugins/package_info_plus/ios" :path: ".symlinks/plugins/package_info_plus/ios"
path_provider_foundation: path_provider_foundation:
:path: ".symlinks/plugins/path_provider_foundation/ios" :path: ".symlinks/plugins/path_provider_foundation/darwin"
pdfx:
:path: ".symlinks/plugins/pdfx/ios"
permission_handler_apple: permission_handler_apple:
:path: ".symlinks/plugins/permission_handler_apple/ios" :path: ".symlinks/plugins/permission_handler_apple/ios"
printing:
:path: ".symlinks/plugins/printing/ios"
receive_sharing_intent: receive_sharing_intent:
:path: ".symlinks/plugins/receive_sharing_intent/ios" :path: ".symlinks/plugins/receive_sharing_intent/ios"
share_plus: share_plus:
:path: ".symlinks/plugins/share_plus/ios" :path: ".symlinks/plugins/share_plus/ios"
shared_preferences_foundation:
:path: ".symlinks/plugins/shared_preferences_foundation/ios"
sqflite: sqflite:
:path: ".symlinks/plugins/sqflite/ios" :path: ".symlinks/plugins/sqflite/ios"
url_launcher_ios: url_launcher_ios:
:path: ".symlinks/plugins/url_launcher_ios/ios" :path: ".symlinks/plugins/url_launcher_ios/ios"
webview_flutter_wkwebview:
:path: ".symlinks/plugins/webview_flutter_wkwebview/ios"
SPEC CHECKSUMS: SPEC CHECKSUMS:
connectivity_plus: 413a8857dd5d9f1c399a39130850d02fe0feaf7e connectivity_plus: 07c49e96d7fc92bc9920617b83238c4d178b446a
device_info_plus: e5c5da33f982a436e103237c0c85f9031142abed device_info_plus: e5c5da33f982a436e103237c0c85f9031142abed
DKImagePickerController: b512c28220a2b8ac7419f21c491fc8534b7601ac DKImagePickerController: b512c28220a2b8ac7419f21c491fc8534b7601ac
DKPhotoGallery: fdfad5125a9fdda9cc57df834d49df790dbb4179 DKPhotoGallery: fdfad5125a9fdda9cc57df834d49df790dbb4179
edge_detection: fa02aa120e00d87ada0ca2430b6c6087a501b1e9 edge_detection: b4fb239b018cefa79515a024d0bf3e559336de4e
file_picker: ce3938a0df3cc1ef404671531facef740d03f920 file_picker: ce3938a0df3cc1ef404671531facef740d03f920
Flutter: f04841e97a9d0b0a8025694d0796dd46242b2854 Flutter: f04841e97a9d0b0a8025694d0796dd46242b2854
flutter_keyboard_visibility: 0339d06371254c3eb25eeb90ba8d17dca8f9c069 flutter_keyboard_visibility: 0339d06371254c3eb25eeb90ba8d17dca8f9c069
flutter_local_notifications: 0c0b1ae97e741e1521e4c1629a459d04b9aec743 flutter_local_notifications: 0c0b1ae97e741e1521e4c1629a459d04b9aec743
flutter_native_splash: 52501b97d1c0a5f898d687f1646226c1f93c56ef flutter_native_splash: 52501b97d1c0a5f898d687f1646226c1f93c56ef
fluttertoast: eb263d302cc92e04176c053d2385237e9f43fad0 flutter_pdfview: 25f53dd6097661e6395b17de506e6060585946bd
flutter_secure_storage: 23fc622d89d073675f2eaa109381aefbcf5a49be
fluttertoast: fafc4fa4d01a6a9e4f772ecd190ffa525e9e2d9c
FMDB: 2ce00b547f966261cd18927a3ddb07cb6f3db82a FMDB: 2ce00b547f966261cd18927a3ddb07cb6f3db82a
in_app_review: 318597b3a06c22bb46dc454d56828c85f444f99d integration_test: 13825b8a9334a850581300559b8839134b124670
integration_test: a1e7d09bd98eca2fc37aefd79d4f41ad37bdbbe5 local_auth_ios: c6cf091ded637a88f24f86a8875d8b0f526e2605
local_auth_ios: 0d333dde7780f669e66f19d2ff6005f3ea84008d
open_filex: 6e26e659846ec990262224a12ef1c528bb4edbe4 open_filex: 6e26e659846ec990262224a12ef1c528bb4edbe4
package_info_plus: 6c92f08e1f853dc01228d6f553146438dafcd14e package_info_plus: fd030dabf36271f146f1f3beacd48f564b0f17f7
path_provider_foundation: 37748e03f12783f9de2cb2c4eadfaa25fe6d4852 path_provider_foundation: 29f094ae23ebbca9d3d0cec13889cd9060c0e943
pdfx: 7b876b09de8b7a0bf444a4f82b439ffcff4ee1ec permission_handler_apple: e76247795d700c14ea09e3a2d8855d41ee80a2e6
permission_handler_apple: 44366e37eaf29454a1e7b1b7d736c2cceaeb17ce printing: 233e1b73bd1f4a05615548e9b5a324c98588640b
ReachabilitySwift: 985039c6f7b23a1da463388634119492ff86c825 ReachabilitySwift: 985039c6f7b23a1da463388634119492ff86c825
receive_sharing_intent: c0d87310754e74c0f9542947e7cbdf3a0335a3b1 receive_sharing_intent: c0d87310754e74c0f9542947e7cbdf3a0335a3b1
SDWebImage: 23d714cd599354ee7906dbae26dff89b421c4370 SDWebImage: 23d714cd599354ee7906dbae26dff89b421c4370
share_plus: 056a1e8ac890df3e33cb503afffaf1e9b4fbae68 share_plus: 056a1e8ac890df3e33cb503afffaf1e9b4fbae68
shared_preferences_foundation: 297b3ebca31b34ec92be11acd7fb0ba932c822ca sqflite: 31f7eba61e3074736dff8807a9b41581e4f7f15a
sqflite: 6d358c025f5b867b29ed92fc697fd34924e11904
SwiftyGif: 6c3eafd0ce693cad58bb63d2b2fb9bacb8552780 SwiftyGif: 6c3eafd0ce693cad58bb63d2b2fb9bacb8552780
Toast: 91b396c56ee72a5790816f40d3a94dd357abc196 Toast: 91b396c56ee72a5790816f40d3a94dd357abc196
url_launcher_ios: fb12c43172927bb5cf75aeebd073f883801f1993 url_launcher_ios: 08a3dfac5fb39e8759aeb0abbd5d9480f30fc8b4
webview_flutter_wkwebview: 2e2d318f21a5e036e2c3f26171342e95908bd60a
WeScan: fed582f6c38014d529afb5aa9ffd1bad38fc72b7 WeScan: fed582f6c38014d529afb5aa9ffd1bad38fc72b7
PODFILE CHECKSUM: 7daa35cc908d9fba025075df27cb57a1ba1ebf13 PODFILE CHECKSUM: 7daa35cc908d9fba025075df27cb57a1ba1ebf13
COCOAPODS: 1.11.3 COCOAPODS: 1.12.0
-8
View File
@@ -1,8 +0,0 @@
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:paperless_mobile/core/model/document_processing_status.dart';
class DocumentStatusCubit extends Cubit<DocumentProcessingStatus?> {
DocumentStatusCubit() : super(null);
void updateStatus(DocumentProcessingStatus? status) => emit(status);
}
-2
View File
@@ -15,11 +15,9 @@ import 'package:paperless_mobile/features/settings/model/view_type.dart';
class HiveBoxes { class HiveBoxes {
HiveBoxes._(); HiveBoxes._();
static const globalSettings = 'globalSettings'; static const globalSettings = 'globalSettings';
static const authentication = 'authentication';
static const localUserCredentials = 'localUserCredentials'; static const localUserCredentials = 'localUserCredentials';
static const localUserAccount = 'localUserAccount'; static const localUserAccount = 'localUserAccount';
static const localUserAppState = 'localUserAppState'; static const localUserAppState = 'localUserAppState';
static const localUserSettings = 'localUserSettings';
static const hosts = 'hosts'; static const hosts = 'hosts';
} }
+23 -2
View File
@@ -4,13 +4,19 @@ import 'dart:typed_data';
import 'package:flutter_secure_storage/flutter_secure_storage.dart'; import 'package:flutter_secure_storage/flutter_secure_storage.dart';
import 'package:hive_flutter/adapters.dart'; import 'package:hive_flutter/adapters.dart';
import 'package:paperless_mobile/core/config/hive/hive_config.dart';
import 'package:paperless_mobile/core/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';
/// ///
/// Opens an encrypted box, calls [callback] with the now opened box, awaits /// Opens an encrypted box, calls [callback] with the now opened box, awaits
/// [callback] to return and returns the calculated value. Closes the box after. /// [callback] to return and returns the calculated value. Closes the box after.
/// ///
Future<R?> withEncryptedBox<T, R>( Future<R?> withEncryptedBox<T, R>(
String name, FutureOr<R?> Function(Box<T> box) callback) async { String name,
FutureOr<R?> Function(Box<T> box) callback,
) async {
final key = await _getEncryptedBoxKey(); final key = await _getEncryptedBoxKey();
final box = await Hive.openBox<T>( final box = await Hive.openBox<T>(
name, name,
@@ -22,7 +28,11 @@ Future<R?> withEncryptedBox<T, R>(
} }
Future<Uint8List> _getEncryptedBoxKey() async { Future<Uint8List> _getEncryptedBoxKey() async {
const secureStorage = FlutterSecureStorage(); const secureStorage = FlutterSecureStorage(
aOptions: AndroidOptions(
encryptedSharedPreferences: true,
),
);
if (!await secureStorage.containsKey(key: 'key')) { if (!await secureStorage.containsKey(key: 'key')) {
final key = Hive.generateSecureKey(); final key = Hive.generateSecureKey();
@@ -34,3 +44,14 @@ Future<Uint8List> _getEncryptedBoxKey() async {
final key = (await secureStorage.read(key: 'key'))!; final key = (await secureStorage.read(key: 'key'))!;
return base64Decode(key); return base64Decode(key);
} }
extension HiveBoxAccessors on HiveInterface {
Box<GlobalSettings> get settingsBox =>
box<GlobalSettings>(HiveBoxes.globalSettings);
Box<LocalUserAccount> get localUserAccountBox =>
box<LocalUserAccount>(HiveBoxes.localUserAccount);
Box<LocalUserAppState> get localUserAppStateBox =>
box<LocalUserAppState>(HiveBoxes.localUserAppState);
Box<GlobalSettings> get globalSettingsBox =>
box<GlobalSettings>(HiveBoxes.globalSettings);
}
@@ -21,7 +21,7 @@ class GlobalSettings with HiveObjectMixin {
bool showOnboarding; bool showOnboarding;
@HiveField(4) @HiveField(4)
String? currentLoggedInUser; String? loggedInUserId;
@HiveField(5) @HiveField(5)
FileDownloadType defaultDownloadType; FileDownloadType defaultDownloadType;
@@ -32,14 +32,18 @@ class GlobalSettings with HiveObjectMixin {
@HiveField(7, defaultValue: false) @HiveField(7, defaultValue: false)
bool enforceSinglePagePdfUpload; bool enforceSinglePagePdfUpload;
@HiveField(8, defaultValue: false)
bool skipDocumentPreprarationOnUpload;
GlobalSettings({ GlobalSettings({
required this.preferredLocaleSubtag, required this.preferredLocaleSubtag,
this.preferredThemeMode = ThemeMode.system, this.preferredThemeMode = ThemeMode.system,
this.preferredColorSchemeOption = ColorSchemeOption.classic, this.preferredColorSchemeOption = ColorSchemeOption.classic,
this.showOnboarding = true, this.showOnboarding = true,
this.currentLoggedInUser, this.loggedInUserId,
this.defaultDownloadType = FileDownloadType.alwaysAsk, this.defaultDownloadType = FileDownloadType.alwaysAsk,
this.defaultShareType = FileDownloadType.alwaysAsk, this.defaultShareType = FileDownloadType.alwaysAsk,
this.enforceSinglePagePdfUpload = false, this.enforceSinglePagePdfUpload = false,
this.skipDocumentPreprarationOnUpload = false,
}); });
} }
@@ -1,8 +1,7 @@
import 'package:hive_flutter/adapters.dart'; import 'package:hive_flutter/adapters.dart';
import 'package:paperless_mobile/core/config/hive/hive_config.dart';
import 'package:paperless_mobile/core/database/tables/global_settings.dart';
import 'package:paperless_mobile/core/database/tables/local_user_settings.dart';
import 'package:paperless_api/paperless_api.dart'; import 'package:paperless_api/paperless_api.dart';
import 'package:paperless_mobile/core/config/hive/hive_config.dart';
import 'package:paperless_mobile/core/database/tables/local_user_settings.dart';
part 'local_user_account.g.dart'; part 'local_user_account.g.dart';
@@ -20,16 +19,16 @@ class LocalUserAccount extends HiveObject {
@HiveField(7) @HiveField(7)
UserModel paperlessUser; UserModel paperlessUser;
@HiveField(8, defaultValue: 2)
int apiVersion;
LocalUserAccount({ LocalUserAccount({
required this.id, required this.id,
required this.serverUrl, required this.serverUrl,
required this.settings, required this.settings,
required this.paperlessUser, required this.paperlessUser,
required this.apiVersion,
}); });
static LocalUserAccount get current => bool get hasMultiUserSupport => apiVersion >= 3;
Hive.box<LocalUserAccount>(HiveBoxes.localUserAccount).get(
Hive.box<GlobalSettings>(HiveBoxes.globalSettings)
.getValue()!
.currentLoggedInUser)!;
} }
@@ -43,7 +43,7 @@ class LocalUserAppState extends HiveObject {
final currentLocalUserId = final currentLocalUserId =
Hive.box<GlobalSettings>(HiveBoxes.globalSettings) Hive.box<GlobalSettings>(HiveBoxes.globalSettings)
.getValue()! .getValue()!
.currentLoggedInUser!; .loggedInUserId!;
return Hive.box<LocalUserAppState>(HiveBoxes.localUserAppState) return Hive.box<LocalUserAppState>(HiveBoxes.localUserAppState)
.get(currentLocalUserId)!; .get(currentLocalUserId)!;
} }
+8 -1
View File
@@ -1 +1,8 @@
const supportedFileExtensions = ['pdf', 'png', 'tiff', 'gif', 'jpg', 'jpeg']; const supportedFileExtensions = [
'.pdf',
'.png',
'.tiff',
'.gif',
'.jpg',
'.jpeg'
];
@@ -39,8 +39,6 @@ class DioHttpErrorInterceptor extends Interceptor {
), ),
); );
} }
} else {
return handler.next(err);
} }
} }
} }
@@ -0,0 +1,12 @@
import 'package:paperless_api/paperless_api.dart';
class InfoMessageException implements Exception {
final ErrorCode code;
final String? message;
final StackTrace? stackTrace;
InfoMessageException({
required this.code,
this.message,
this.stackTrace,
});
}
-391
View File
@@ -1,391 +0,0 @@
import 'dart:typed_data';
import 'package:connectivity_plus/connectivity_plus.dart';
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:flutter_cache_manager/flutter_cache_manager.dart';
import 'package:hive/hive.dart';
import 'package:paperless_api/paperless_api.dart';
import 'package:paperless_mobile/core/bloc/connectivity_cubit.dart';
import 'package:paperless_mobile/core/config/hive/hive_config.dart';
import 'package:paperless_mobile/core/database/tables/global_settings.dart';
import 'package:paperless_mobile/core/database/tables/local_user_app_state.dart';
import 'package:paperless_mobile/core/notifier/document_changed_notifier.dart';
import 'package:paperless_mobile/core/repository/label_repository.dart';
import 'package:paperless_mobile/core/repository/user_repository.dart';
import 'package:paperless_mobile/features/document_bulk_action/cubit/document_bulk_action_cubit.dart';
import 'package:paperless_mobile/features/document_bulk_action/view/widgets/fullscreen_bulk_edit_label_page.dart';
import 'package:paperless_mobile/features/document_bulk_action/view/widgets/fullscreen_bulk_edit_tags_widget.dart';
import 'package:paperless_mobile/features/document_search/cubit/document_search_cubit.dart';
import 'package:paperless_mobile/features/document_search/view/document_search_page.dart';
import 'package:paperless_mobile/features/document_upload/cubit/document_upload_cubit.dart';
import 'package:paperless_mobile/features/document_upload/view/document_upload_preparation_page.dart';
import 'package:paperless_mobile/features/home/view/model/api_version.dart';
import 'package:paperless_mobile/features/linked_documents/cubit/linked_documents_cubit.dart';
import 'package:paperless_mobile/features/linked_documents/view/linked_documents_page.dart';
import 'package:paperless_mobile/features/notifications/services/local_notification_service.dart';
import 'package:paperless_mobile/features/saved_view/cubit/saved_view_cubit.dart';
import 'package:paperless_mobile/features/saved_view/view/add_saved_view_page.dart';
import 'package:paperless_mobile/features/saved_view_details/cubit/saved_view_details_cubit.dart';
import 'package:paperless_mobile/features/saved_view_details/view/saved_view_details_page.dart';
import 'package:paperless_mobile/generated/l10n/app_localizations.dart';
import 'package:paperless_mobile/routes/document_details_route.dart';
import 'package:provider/provider.dart';
// These are convenience methods for nativating to views without having to pass providers around explicitly.
// Providers unfortunately have to be passed to the routes since they are children of the Navigator, not ancestors.
Future<void> pushDocumentSearchPage(BuildContext context) {
final currentUser = Hive.box<GlobalSettings>(HiveBoxes.globalSettings)
.getValue()!
.currentLoggedInUser;
final userRepo = context.read<UserRepository>();
return Navigator.of(context).push(
MaterialPageRoute(
builder: (_) => MultiProvider(
providers: [
Provider.value(value: context.read<LabelRepository>()),
Provider.value(value: context.read<PaperlessDocumentsApi>()),
Provider.value(value: context.read<DocumentChangedNotifier>()),
Provider.value(value: context.read<CacheManager>()),
Provider.value(value: userRepo),
],
builder: (context, _) {
return BlocProvider(
create: (context) => DocumentSearchCubit(
context.read(),
context.read(),
Hive.box<LocalUserAppState>(HiveBoxes.localUserAppState)
.get(currentUser)!,
),
child: const DocumentSearchPage(),
);
},
),
),
);
}
Future<void> pushDocumentDetailsRoute(
BuildContext context, {
required DocumentModel document,
bool isLabelClickable = true,
bool allowEdit = true,
String? titleAndContentQueryString,
}) {
return Navigator.of(context).push(
MaterialPageRoute(
builder: (_) => MultiProvider(
providers: [
Provider.value(value: context.read<ApiVersion>()),
Provider.value(value: context.read<LabelRepository>()),
Provider.value(value: context.read<DocumentChangedNotifier>()),
Provider.value(value: context.read<PaperlessDocumentsApi>()),
Provider.value(value: context.read<LocalNotificationService>()),
Provider.value(value: context.read<CacheManager>()),
Provider.value(value: context.read<ConnectivityCubit>()),
if (context.read<ApiVersion>().hasMultiUserSupport)
Provider.value(value: context.read<UserRepository>()),
],
child: DocumentDetailsRoute(
document: document,
isLabelClickable: isLabelClickable,
),
),
),
);
}
Future<void> pushSavedViewDetailsRoute(
BuildContext context, {
required SavedView savedView,
}) {
final apiVersion = context.read<ApiVersion>();
return Navigator.of(context).push(
MaterialPageRoute(
builder: (_) => MultiProvider(
providers: [
Provider.value(value: apiVersion),
if (apiVersion.hasMultiUserSupport)
Provider.value(value: context.read<UserRepository>()),
Provider.value(value: context.read<LabelRepository>()),
Provider.value(value: context.read<DocumentChangedNotifier>()),
Provider.value(value: context.read<PaperlessDocumentsApi>()),
Provider.value(value: context.read<CacheManager>()),
Provider.value(value: context.read<ConnectivityCubit>()),
],
builder: (_, child) {
return BlocProvider(
create: (context) => SavedViewDetailsCubit(
context.read(),
context.read(),
context.read(),
LocalUserAppState.current,
savedView: savedView,
),
child: SavedViewDetailsPage(
onDelete: context.read<SavedViewCubit>().remove),
);
},
),
),
);
}
Future<SavedView?> pushAddSavedViewRoute(BuildContext context,
{required DocumentFilter filter}) {
return Navigator.of(context).push<SavedView?>(
MaterialPageRoute(
builder: (_) => AddSavedViewPage(
currentFilter: filter,
correspondents: context.read<LabelRepository>().state.correspondents,
documentTypes: context.read<LabelRepository>().state.documentTypes,
storagePaths: context.read<LabelRepository>().state.storagePaths,
tags: context.read<LabelRepository>().state.tags,
),
),
);
}
Future<void> pushLinkedDocumentsView(BuildContext context,
{required DocumentFilter filter}) {
return Navigator.push(
context,
MaterialPageRoute(
builder: (_) => MultiProvider(
providers: [
Provider.value(value: context.read<ApiVersion>()),
Provider.value(value: context.read<LabelRepository>()),
Provider.value(value: context.read<DocumentChangedNotifier>()),
Provider.value(value: context.read<PaperlessDocumentsApi>()),
Provider.value(value: context.read<LocalNotificationService>()),
Provider.value(value: context.read<CacheManager>()),
Provider.value(value: context.read<ConnectivityCubit>()),
if (context.read<ApiVersion>().hasMultiUserSupport)
Provider.value(value: context.read<UserRepository>()),
],
builder: (context, _) => BlocProvider(
create: (context) => LinkedDocumentsCubit(
filter,
context.read(),
context.read(),
context.read(),
),
child: const LinkedDocumentsPage(),
),
),
),
);
}
Future<void> pushBulkEditCorrespondentRoute(
BuildContext context, {
required List<DocumentModel> selection,
}) {
return Navigator.of(context).push(
MaterialPageRoute(
builder: (_) => MultiProvider(
providers: [
..._getRequiredBulkEditProviders(context),
],
builder: (_, __) => BlocProvider(
create: (_) => DocumentBulkActionCubit(
context.read(),
context.read(),
context.read(),
selection: selection,
),
child: BlocBuilder<DocumentBulkActionCubit, DocumentBulkActionState>(
builder: (context, state) {
return FullscreenBulkEditLabelPage(
options: state.correspondents,
selection: state.selection,
labelMapper: (document) => document.correspondent,
leadingIcon: const Icon(Icons.person_outline),
hintText: S.of(context)!.startTyping,
onSubmit: context
.read<DocumentBulkActionCubit>()
.bulkModifyCorrespondent,
assignMessageBuilder: (int count, String name) {
return S.of(context)!.bulkEditCorrespondentAssignMessage(
name,
count,
);
},
removeMessageBuilder: (int count) {
return S
.of(context)!
.bulkEditCorrespondentRemoveMessage(count);
},
);
},
),
),
),
),
);
}
Future<void> pushBulkEditStoragePathRoute(
BuildContext context, {
required List<DocumentModel> selection,
}) {
return Navigator.of(context).push(
MaterialPageRoute(
builder: (_) => MultiProvider(
providers: [
..._getRequiredBulkEditProviders(context),
],
builder: (_, __) => BlocProvider(
create: (_) => DocumentBulkActionCubit(
context.read(),
context.read(),
context.read(),
selection: selection,
),
child: BlocBuilder<DocumentBulkActionCubit, DocumentBulkActionState>(
builder: (context, state) {
return FullscreenBulkEditLabelPage(
options: state.storagePaths,
selection: state.selection,
labelMapper: (document) => document.storagePath,
leadingIcon: const Icon(Icons.folder_outlined),
hintText: S.of(context)!.startTyping,
onSubmit: context
.read<DocumentBulkActionCubit>()
.bulkModifyStoragePath,
assignMessageBuilder: (int count, String name) {
return S.of(context)!.bulkEditStoragePathAssignMessage(
count,
name,
);
},
removeMessageBuilder: (int count) {
return S.of(context)!.bulkEditStoragePathRemoveMessage(count);
},
);
},
),
),
),
),
);
}
Future<void> pushBulkEditTagsRoute(
BuildContext context, {
required List<DocumentModel> selection,
}) {
return Navigator.of(context).push(
MaterialPageRoute(
builder: (_) => MultiProvider(
providers: [
..._getRequiredBulkEditProviders(context),
],
builder: (_, __) => BlocProvider(
create: (_) => DocumentBulkActionCubit(
context.read(),
context.read(),
context.read(),
selection: selection,
),
child: Builder(builder: (context) {
return const FullscreenBulkEditTagsWidget();
}),
),
),
),
);
}
Future<void> pushBulkEditDocumentTypeRoute(BuildContext context,
{required List<DocumentModel> selection}) {
return Navigator.of(context).push(
MaterialPageRoute(
builder: (_) => MultiProvider(
providers: [
..._getRequiredBulkEditProviders(context),
],
builder: (_, __) => BlocProvider(
create: (_) => DocumentBulkActionCubit(
context.read(),
context.read(),
context.read(),
selection: selection,
),
child: BlocBuilder<DocumentBulkActionCubit, DocumentBulkActionState>(
builder: (context, state) {
return FullscreenBulkEditLabelPage(
options: state.documentTypes,
selection: state.selection,
labelMapper: (document) => document.documentType,
leadingIcon: const Icon(Icons.description_outlined),
hintText: S.of(context)!.startTyping,
onSubmit: context
.read<DocumentBulkActionCubit>()
.bulkModifyDocumentType,
assignMessageBuilder: (int count, String name) {
return S.of(context)!.bulkEditDocumentTypeAssignMessage(
count,
name,
);
},
removeMessageBuilder: (int count) {
return S
.of(context)!
.bulkEditDocumentTypeRemoveMessage(count);
},
);
},
),
),
),
),
);
}
Future<DocumentUploadResult?> pushDocumentUploadPreparationPage(
BuildContext context, {
required Uint8List bytes,
String? filename,
String? fileExtension,
String? title,
}) {
final labelRepo = context.read<LabelRepository>();
final docsApi = context.read<PaperlessDocumentsApi>();
final connectivity = context.read<Connectivity>();
final apiVersion = context.read<ApiVersion>();
return Navigator.of(context).push<DocumentUploadResult>(
MaterialPageRoute(
builder: (_) => MultiProvider(
providers: [
Provider.value(value: labelRepo),
Provider.value(value: docsApi),
Provider.value(value: connectivity),
Provider.value(value: apiVersion)
],
builder: (_, child) => BlocProvider(
create: (_) => DocumentUploadCubit(
context.read(),
context.read(),
context.read(),
),
child: DocumentUploadPreparationPage(
fileBytes: bytes,
fileExtension: fileExtension,
filename: filename,
title: title,
),
),
),
),
);
}
List<Provider> _getRequiredBulkEditProviders(BuildContext context) {
return [
Provider.value(value: context.read<PaperlessDocumentsApi>()),
Provider.value(value: context.read<LabelRepository>()),
Provider.value(value: context.read<DocumentChangedNotifier>()),
];
}
@@ -12,6 +12,10 @@ class DocumentChangedNotifier {
final Map<dynamic, List<StreamSubscription>> _subscribers = {}; final Map<dynamic, List<StreamSubscription>> _subscribers = {};
Stream<DocumentModel> get $updated => _updated.asBroadcastStream();
Stream<DocumentModel> get $deleted => _deleted.asBroadcastStream();
void notifyUpdated(DocumentModel updated) { void notifyUpdated(DocumentModel updated) {
debugPrint("Notifying updated document ${updated.id}"); debugPrint("Notifying updated document ${updated.id}");
_updated.add(updated); _updated.add(updated);
@@ -8,25 +8,26 @@ abstract class PersistentRepository<T> extends HydratedCubit<T> {
PersistentRepository(T initialState) : super(initialState); PersistentRepository(T initialState) : super(initialState);
void addListener( void addListener(
Object source, { Object subscriber, {
required void Function(T) onChanged, required void Function(T) onChanged,
}) { }) {
onChanged(state); onChanged(state);
_subscribers.putIfAbsent(source, () { _subscribers.putIfAbsent(subscriber, () {
return stream.listen((event) => onChanged(event)); return stream.listen((event) => onChanged(event));
}); });
} }
void removeListener(Object source) async { void removeListener(Object source) async {
await _subscribers[source]?.cancel(); _subscribers
_subscribers.remove(source); ..[source]?.cancel()
..remove(source);
} }
@override @override
Future<void> close() { Future<void> close() {
_subscribers.forEach((key, subscription) { for (final subscriber in _subscribers.values) {
subscription.cancel(); subscriber.cancel();
}); }
return super.close(); return super.close();
} }
} }
@@ -35,6 +35,18 @@ class SavedViewRepository
return created; return created;
} }
Future<SavedView> update(SavedView object) async {
await _initialized.future;
final updated = await _api.update(object);
final updatedState = {...state.savedViews}..update(
updated.id!,
(_) => updated,
ifAbsent: () => updated,
);
emit(SavedViewRepositoryState.loaded(savedViews: updatedState));
return updated;
}
Future<int> delete(SavedView view) async { Future<int> delete(SavedView view) async {
await _initialized.future; await _initialized.future;
await _api.delete(view); await _api.delete(view);
+2
View File
@@ -4,6 +4,7 @@ import 'package:dio/dio.dart';
import 'package:dio/io.dart'; import 'package:dio/io.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:paperless_mobile/core/interceptor/dio_http_error_interceptor.dart'; import 'package:paperless_mobile/core/interceptor/dio_http_error_interceptor.dart';
import 'package:paperless_mobile/core/interceptor/dio_offline_interceptor.dart';
import 'package:paperless_mobile/core/interceptor/dio_unauthorized_interceptor.dart'; import 'package:paperless_mobile/core/interceptor/dio_unauthorized_interceptor.dart';
import 'package:paperless_mobile/core/interceptor/retry_on_connection_change_interceptor.dart'; import 'package:paperless_mobile/core/interceptor/retry_on_connection_change_interceptor.dart';
import 'package:paperless_mobile/features/login/model/client_certificate.dart'; import 'package:paperless_mobile/features/login/model/client_certificate.dart';
@@ -37,6 +38,7 @@ class SessionManager extends ValueNotifier<Dio> {
...interceptors, ...interceptors,
DioUnauthorizedInterceptor(), DioUnauthorizedInterceptor(),
DioHttpErrorInterceptor(), DioHttpErrorInterceptor(),
DioOfflineInterceptor(),
PrettyDioLogger( PrettyDioLogger(
compact: true, compact: true,
responseBody: false, responseBody: false,
@@ -7,6 +7,7 @@ import 'package:paperless_mobile/core/interceptor/server_reachability_error_inte
import 'package:paperless_mobile/core/security/session_manager.dart'; import 'package:paperless_mobile/core/security/session_manager.dart';
import 'package:paperless_mobile/features/login/model/client_certificate.dart'; import 'package:paperless_mobile/features/login/model/client_certificate.dart';
import 'package:paperless_mobile/features/login/model/reachability_status.dart'; import 'package:paperless_mobile/features/login/model/reachability_status.dart';
import 'package:rxdart/subjects.dart';
abstract class ConnectivityStatusService { abstract class ConnectivityStatusService {
Future<bool> isConnectedToInternet(); Future<bool> isConnectedToInternet();
@@ -20,14 +21,19 @@ abstract class ConnectivityStatusService {
class ConnectivityStatusServiceImpl implements ConnectivityStatusService { class ConnectivityStatusServiceImpl implements ConnectivityStatusService {
final Connectivity _connectivity; final Connectivity _connectivity;
final BehaviorSubject<bool> _connectivityState$ = BehaviorSubject();
ConnectivityStatusServiceImpl(this._connectivity); ConnectivityStatusServiceImpl(this._connectivity) {
_connectivityState$.addStream(
_connectivity.onConnectivityChanged
.map(_hasActiveInternetConnection)
.asBroadcastStream(),
);
}
@override @override
Stream<bool> connectivityChanges() { Stream<bool> connectivityChanges() {
return _connectivity.onConnectivityChanged return _connectivityState$.asBroadcastStream();
.map(_hasActiveInternetConnection)
.asBroadcastStream();
} }
@override @override
@@ -98,3 +104,31 @@ class ConnectivityStatusServiceImpl implements ConnectivityStatusService {
return ReachabilityStatus.notReachable; return ReachabilityStatus.notReachable;
} }
} }
class ConnectivityStatusServiceMock implements ConnectivityStatusService {
final bool isConnected;
ConnectivityStatusServiceMock(this.isConnected);
@override
Stream<bool> connectivityChanges() {
return Stream.value(isConnected);
}
@override
Future<bool> isConnectedToInternet() async {
return isConnected;
}
@override
Future<ReachabilityStatus> isPaperlessServerReachable(String serverAddress,
[ClientCertificate? clientCertificate]) async {
return isConnected
? ReachabilityStatus.reachable
: ReachabilityStatus.notReachable;
}
@override
Future<bool> isServerReachable(String serverAddress) async {
return isConnected;
}
}
-20
View File
@@ -1,20 +0,0 @@
class FileDescription {
final String filename;
final String extension;
FileDescription({
required this.filename,
required this.extension,
});
factory FileDescription.fromPath(String path) {
final filename = path.split(RegExp(r"/")).last;
final fragments = filename.split(".");
final ext = fragments.removeLast();
final name = fragments.join(".");
return FileDescription(
filename: name,
extension: ext,
);
}
}
+47 -39
View File
@@ -1,34 +1,30 @@
import 'dart:io'; import 'dart:io';
import 'package:flutter/foundation.dart'; import 'package:flutter/foundation.dart';
import 'package:paperless_api/paperless_api.dart';
import 'package:path_provider/path_provider.dart'; import 'package:path_provider/path_provider.dart';
import 'package:rxdart/rxdart.dart';
import 'package:uuid/uuid.dart'; import 'package:uuid/uuid.dart';
class FileService { class FileService {
const FileService._();
static Future<File> saveToFile( static Future<File> saveToFile(
Uint8List bytes, Uint8List bytes,
String filename, String filename,
) async { ) async {
final dir = await documentsDirectory; final dir = await documentsDirectory;
if (dir == null) {
throw const PaperlessApiException.unknown(); //TODO: better handling
}
File file = File("${dir.path}/$filename"); File file = File("${dir.path}/$filename");
return file..writeAsBytes(bytes); return file..writeAsBytes(bytes);
} }
static Future<Directory?> getDirectory(PaperlessDirectoryType type) { static Future<Directory?> getDirectory(PaperlessDirectoryType type) {
switch (type) { return switch (type) {
case PaperlessDirectoryType.documents: PaperlessDirectoryType.documents => documentsDirectory,
return documentsDirectory; PaperlessDirectoryType.temporary => temporaryDirectory,
case PaperlessDirectoryType.temporary: PaperlessDirectoryType.scans => temporaryScansDirectory,
return temporaryDirectory; PaperlessDirectoryType.download => downloadsDirectory,
case PaperlessDirectoryType.scans: PaperlessDirectoryType.upload => uploadDirectory,
return scanDirectory; };
case PaperlessDirectoryType.download:
return downloadsDirectory;
}
} }
static Future<File> allocateTemporaryFile( static Future<File> allocateTemporaryFile(
@@ -43,17 +39,16 @@ class FileService {
static Future<Directory> get temporaryDirectory => getTemporaryDirectory(); static Future<Directory> get temporaryDirectory => getTemporaryDirectory();
static Future<Directory?> get documentsDirectory async { static Future<Directory> get documentsDirectory async {
if (Platform.isAndroid) { if (Platform.isAndroid) {
return (await getExternalStorageDirectories( return (await getExternalStorageDirectories(
type: StorageDirectory.documents, type: StorageDirectory.documents,
))! ))!
.first; .first;
} else if (Platform.isIOS) { } else if (Platform.isIOS) {
final appDir = await getApplicationDocumentsDirectory(); final dir = await getApplicationDocumentsDirectory()
final dir = Directory('${appDir.path}/documents'); .then((dir) => Directory('${dir.path}/documents'));
dir.createSync(); return dir.create(recursive: true);
return dir;
} else { } else {
throw UnsupportedError("Platform not supported."); throw UnsupportedError("Platform not supported.");
} }
@@ -72,34 +67,38 @@ class FileService {
} else if (Platform.isIOS) { } else if (Platform.isIOS) {
final appDir = await getApplicationDocumentsDirectory(); final appDir = await getApplicationDocumentsDirectory();
final dir = Directory('${appDir.path}/downloads'); final dir = Directory('${appDir.path}/downloads');
dir.createSync(); return dir.create(recursive: true);
return dir;
} else { } else {
throw UnsupportedError("Platform not supported."); throw UnsupportedError("Platform not supported.");
} }
} }
static Future<Directory?> get scanDirectory async { static Future<Directory> get uploadDirectory async {
if (Platform.isAndroid) { final dir = await getApplicationDocumentsDirectory()
final scanDir = await getExternalStorageDirectories( .then((dir) => Directory('${dir.path}/upload'));
type: StorageDirectory.dcim, return dir.create(recursive: true);
);
return scanDir!.first;
} else if (Platform.isIOS) {
final appDir = await getApplicationDocumentsDirectory();
final dir = Directory('${appDir.path}/scans');
dir.createSync();
return dir;
} else {
throw UnsupportedError("Platform not supported.");
}
} }
static Future<void> clearUserData() async { static Future<Directory> getConsumptionDirectory(
final scanDir = await scanDirectory; {required String userId}) async {
final uploadDir =
await uploadDirectory.then((dir) => Directory('${dir.path}/$userId'));
return uploadDir.create(recursive: true);
}
static Future<Directory> get temporaryScansDirectory async {
final tempDir = await temporaryDirectory; final tempDir = await temporaryDirectory;
await scanDir?.delete(recursive: true); final scansDir = Directory('${tempDir.path}/scans');
return scansDir.create(recursive: true);
}
static Future<void> 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 tempDir.delete(recursive: true);
await consumptionDir.delete(recursive: true);
} }
static Future<void> clearDirectoryContent(PaperlessDirectoryType type) async { static Future<void> clearDirectoryContent(PaperlessDirectoryType type) async {
@@ -113,11 +112,20 @@ class FileService {
dir.listSync().map((item) => item.delete(recursive: true)), dir.listSync().map((item) => item.delete(recursive: true)),
); );
} }
static Future<List<File>> getAllFiles(Directory directory) {
return directory.list().whereType<File>().toList();
}
static Future<List<Directory>> getAllSubdirectories(Directory directory) {
return directory.list().whereType<Directory>().toList();
}
} }
enum PaperlessDirectoryType { enum PaperlessDirectoryType {
documents, documents,
temporary, temporary,
scans, scans,
download; download,
upload;
} }
-103
View File
@@ -1,103 +0,0 @@
import 'dart:io';
import 'package:dio/dio.dart';
import 'package:paperless_mobile/core/database/tables/user_credentials.dart';
// import 'package:web_socket_channel/io.dart';
abstract class StatusService {
Future<void> startListeningBeforeDocumentUpload(
String httpUrl, UserCredentials credentials, String documentFileName);
}
class WebSocketStatusService implements StatusService {
late WebSocket? socket;
// late IOWebSocketChannel? _channel;
WebSocketStatusService();
@override
Future<void> startListeningBeforeDocumentUpload(
String httpUrl,
UserCredentials credentials,
String documentFileName,
) async {
// socket = await WebSocket.connect(
// httpUrl.replaceFirst("http", "ws") + "/ws/status/",
// customClient: getIt<HttpClient>(),
// headers: {
// 'Authorization': 'Token ${credentials.token}',
// },
// ).catchError((_) {
// // Use long polling if connection could not be established
// });
// if (socket != null) {
// socket!.where(isNotNull).listen((event) {
// final status = DocumentProcessingStatus.fromJson(event);
// getIt<DocumentStatusCubit>().updateStatus(status);
// if (status.currentProgress == 100) {
// socket!.close();
// }
// });
// }
}
}
class LongPollingStatusService implements StatusService {
final Dio client;
const LongPollingStatusService(this.client);
@override
Future<void> startListeningBeforeDocumentUpload(
String httpUrl,
UserCredentials credentials,
String documentFileName,
) async {
// final today = DateTime.now();
// bool consumptionFinished = false;
// int retryCount = 0;
// getIt<DocumentStatusCubit>().updateStatus(
// DocumentProcessingStatus(
// currentProgress: 0,
// filename: documentFileName,
// maxProgress: 100,
// message: ProcessingMessage.new_file,
// status: ProcessingStatus.working,
// taskId: DocumentProcessingStatus.unknownTaskId,
// documentId: null,
// isApproximated: true,
// ),
// );
// do {
// final response = await httpClient.get(
// Uri.parse(
// '$httpUrl/api/documents/?query=$documentFileName added:${formatDate(today)}'),
// );
// final data = await compute(
// PagedSearchResult.fromJson,
// PagedSearchResultJsonSerializer(
// jsonDecode(response.body), DocumentModel.fromJson),
// );
// if (data.count > 0) {
// consumptionFinished = true;
// final docId = data.results[0].id;
// getIt<DocumentStatusCubit>().updateStatus(
// DocumentProcessingStatus(
// currentProgress: 100,
// filename: documentFileName,
// maxProgress: 100,
// message: ProcessingMessage.finished,
// status: ProcessingStatus.success,
// taskId: DocumentProcessingStatus.unknownTaskId,
// documentId: docId,
// isApproximated: true,
// ),
// );
// return;
// }
// sleep(const Duration(seconds: 1));
// } while (!consumptionFinished && retryCount < maxRetries);
}
}
@@ -54,24 +54,27 @@ String translateError(BuildContext context, ErrorCode code) {
ErrorCode.suggestionsQueryError => S.of(context)!.couldNotLoadSuggestions, ErrorCode.suggestionsQueryError => S.of(context)!.couldNotLoadSuggestions,
ErrorCode.acknowledgeTasksError => S.of(context)!.couldNotAcknowledgeTasks, ErrorCode.acknowledgeTasksError => S.of(context)!.couldNotAcknowledgeTasks,
ErrorCode.correspondentDeleteFailed => ErrorCode.correspondentDeleteFailed =>
"Could not delete correspondent, please try again.", S.of(context)!.couldNotDeleteCorrespondent,
ErrorCode.documentTypeDeleteFailed => ErrorCode.documentTypeDeleteFailed =>
"Could not delete document type, please try again.", S.of(context)!.couldNotDeleteDocumentType,
ErrorCode.tagDeleteFailed => "Could not delete tag, please try again.", ErrorCode.tagDeleteFailed => S.of(context)!.couldNotDeleteTag,
ErrorCode.correspondentUpdateFailed =>
"Could not update correspondent, please try again.",
ErrorCode.documentTypeUpdateFailed =>
"Could not update document type, please try again.",
ErrorCode.tagUpdateFailed => "Could not update tag, please try again.",
ErrorCode.storagePathDeleteFailed => ErrorCode.storagePathDeleteFailed =>
"Could not delete storage path, please try again.", S.of(context)!.couldNotDeleteStoragePath,
ErrorCode.correspondentUpdateFailed =>
S.of(context)!.couldNotUpdateCorrespondent,
ErrorCode.documentTypeUpdateFailed =>
S.of(context)!.couldNotUpdateDocumentType,
ErrorCode.tagUpdateFailed => S.of(context)!.couldNotUpdateTag,
ErrorCode.storagePathUpdateFailed => ErrorCode.storagePathUpdateFailed =>
"Could not update storage path, please try again.", S.of(context)!.couldNotUpdateStoragePath,
ErrorCode.serverInformationLoadFailed => ErrorCode.serverInformationLoadFailed =>
"Could not load server information.", S.of(context)!.couldNotLoadServerInformation,
ErrorCode.serverStatisticsLoadFailed => "Could not load server statistics.", ErrorCode.serverStatisticsLoadFailed =>
ErrorCode.uiSettingsLoadFailed => "Could not load UI settings", S.of(context)!.couldNotLoadStatistics,
ErrorCode.loadTasksError => "Could not load tasks.", ErrorCode.uiSettingsLoadFailed => S.of(context)!.couldNotLoadUISettings,
ErrorCode.userNotFound => "User could not be found.", ErrorCode.loadTasksError => S.of(context)!.couldNotLoadTasks,
ErrorCode.userNotFound => S.of(context)!.userNotFound,
ErrorCode.updateSavedViewError => S.of(context)!.couldNotUpdateSavedView,
ErrorCode.userAlreadyExists => S.of(context)!.userAlreadyExists,
}; };
} }
@@ -1,218 +0,0 @@
// import 'package:flutter/material.dart';
// import 'package:flutter_bloc/flutter_bloc.dart';
// import 'package:paperless_mobile/constants.dart';
// import 'package:paperless_mobile/features/settings/bloc/application_settings_cubit.dart';
// import 'package:paperless_mobile/features/settings/bloc/application_settings_state.dart';
// import 'package:paperless_mobile/features/settings/model/view_type.dart';
// import 'package:paperless_mobile/features/settings/view/settings_page.dart';
// import 'package:paperless_mobile/generated/l10n/app_localizations.dart';
// import 'package:url_launcher/link.dart';
// import 'package:url_launcher/url_launcher_string.dart';
// /// Declares selectable actions in menu.
// enum AppPopupMenuEntries {
// // Documents preview
// documentsSelectListView,
// documentsSelectGridView,
// // Generic actions
// openAboutThisAppDialog,
// reportBug,
// openSettings,
// // Adds a divider
// divider;
// }
// class AppOptionsPopupMenu extends StatelessWidget {
// final List<AppPopupMenuEntries> displayedActions;
// const AppOptionsPopupMenu({
// super.key,
// required this.displayedActions,
// });
// @override
// Widget build(BuildContext context) {
// return PopupMenuButton<AppPopupMenuEntries>(
// position: PopupMenuPosition.under,
// icon: const Icon(Icons.more_vert),
// onSelected: (action) {
// switch (action) {
// case AppPopupMenuEntries.documentsSelectListView:
// context.read<ApplicationSettingsCubit>().setViewType(ViewType.list);
// break;
// case AppPopupMenuEntries.documentsSelectGridView:
// context.read<ApplicationSettingsCubit>().setViewType(ViewType.grid);
// break;
// case AppPopupMenuEntries.openAboutThisAppDialog:
// _showAboutDialog(context);
// break;
// case AppPopupMenuEntries.openSettings:
// Navigator.of(context).push(
// MaterialPageRoute(
// builder: (context) => BlocProvider.value(
// value: context.read<ApplicationSettingsCubit>(),
// child: const SettingsPage(),
// ),
// ),
// );
// break;
// case AppPopupMenuEntries.reportBug:
// launchUrlString(
// 'https://github.com/astubenbord/paperless-mobile/issues/new',
// );
// break;
// default:
// break;
// }
// },
// itemBuilder: _buildEntries,
// );
// }
// PopupMenuItem<AppPopupMenuEntries> _buildReportBugTile(BuildContext context) {
// return PopupMenuItem(
// value: AppPopupMenuEntries.reportBug,
// padding: EdgeInsets.zero,
// child: ListTile(
// leading: const Icon(Icons.bug_report),
// title: Text(S.of(context)!.reportABug),
// ),
// );
// }
// PopupMenuItem<AppPopupMenuEntries> _buildSettingsTile(BuildContext context) {
// return PopupMenuItem(
// padding: EdgeInsets.zero,
// value: AppPopupMenuEntries.openSettings,
// child: ListTile(
// leading: const Icon(Icons.settings_outlined),
// title: Text(S.of(context)!.settings),
// ),
// );
// }
// PopupMenuItem<AppPopupMenuEntries> _buildAboutTile(BuildContext context) {
// return PopupMenuItem(
// padding: EdgeInsets.zero,
// value: AppPopupMenuEntries.openAboutThisAppDialog,
// child: ListTile(
// leading: const Icon(Icons.info_outline),
// title: Text(S.of(context)!.aboutThisApp),
// ),
// );
// }
// PopupMenuItem<AppPopupMenuEntries> _buildListViewTile() {
// return PopupMenuItem(
// padding: EdgeInsets.zero,
// child: BlocBuilder<ApplicationSettingsCubit, ApplicationSettingsState>(
// builder: (context, state) {
// return ListTile(
// leading: const Icon(Icons.list),
// title: const Text("List"),
// trailing: state.preferredViewType == ViewType.list
// ? const Icon(Icons.check)
// : null,
// );
// },
// ),
// value: AppPopupMenuEntries.documentsSelectListView,
// );
// }
// PopupMenuItem<AppPopupMenuEntries> _buildGridViewTile() {
// return PopupMenuItem(
// value: AppPopupMenuEntries.documentsSelectGridView,
// padding: EdgeInsets.zero,
// child: BlocBuilder<ApplicationSettingsCubit, ApplicationSettingsState>(
// builder: (context, state) {
// return ListTile(
// leading: const Icon(Icons.grid_view_rounded),
// title: const Text("Grid"),
// trailing: state.preferredViewType == ViewType.grid
// ? const Icon(Icons.check)
// : null,
// );
// },
// ),
// );
// }
// void _showAboutDialog(BuildContext context) {
// showAboutDialog(
// context: context,
// applicationIcon: const ImageIcon(
// AssetImage('assets/logos/paperless_logo_green.png'),
// ),
// applicationName: 'Paperless Mobile',
// applicationVersion: packageInfo.version + '+' + packageInfo.buildNumber,
// children: [
// Text(S.of(context)!.developedBy('Anton Stubenbord')),
// Link(
// uri: Uri.parse('https://github.com/astubenbord/paperless-mobile'),
// builder: (context, followLink) => GestureDetector(
// onTap: followLink,
// child: Text(
// 'https://github.com/astubenbord/paperless-mobile',
// style: TextStyle(color: Theme.of(context).colorScheme.tertiary),
// ),
// ),
// ),
// const SizedBox(height: 16),
// Text(
// 'Credits',
// style: Theme.of(context).textTheme.titleMedium,
// ),
// _buildOnboardingImageCredits(),
// ],
// );
// }
// Widget _buildOnboardingImageCredits() {
// return Link(
// uri: Uri.parse(
// 'https://www.freepik.com/free-vector/business-team-working-cogwheel-mechanism-together_8270974.htm#query=setting&position=4&from_view=author'),
// builder: (context, followLink) => Wrap(
// children: [
// const Text('Onboarding images by '),
// GestureDetector(
// onTap: followLink,
// child: Text(
// 'pch.vector',
// style: TextStyle(color: Theme.of(context).colorScheme.tertiary),
// ),
// ),
// const Text(' on Freepik.')
// ],
// ),
// );
// }
// List<PopupMenuEntry<AppPopupMenuEntries>> _buildEntries(
// BuildContext context) {
// List<PopupMenuEntry<AppPopupMenuEntries>> items = [];
// for (final entry in displayedActions) {
// switch (entry) {
// case AppPopupMenuEntries.documentsSelectListView:
// items.add(_buildListViewTile());
// break;
// case AppPopupMenuEntries.documentsSelectGridView:
// items.add(_buildGridViewTile());
// break;
// case AppPopupMenuEntries.openAboutThisAppDialog:
// items.add(_buildAboutTile(context));
// break;
// case AppPopupMenuEntries.reportBug:
// items.add(_buildReportBugTile(context));
// break;
// case AppPopupMenuEntries.openSettings:
// items.add(_buildSettingsTile(context));
// break;
// case AppPopupMenuEntries.divider:
// items.add(const PopupMenuDivider());
// break;
// }
// }
// return items;
// }
// }
@@ -0,0 +1,31 @@
import 'package:flutter/material.dart';
import 'package:paperless_mobile/core/widgets/dialog_utils/unsaved_changes_warning_dialog.dart';
class PopWithUnsavedChanges extends StatelessWidget {
final bool Function() hasChangesPredicate;
final Widget child;
const PopWithUnsavedChanges({
super.key,
required this.hasChangesPredicate,
required this.child,
});
@override
Widget build(BuildContext context) {
return WillPopScope(
onWillPop: () async {
if (hasChangesPredicate()) {
final shouldPop = await showDialog<bool>(
context: context,
builder: (context) => const UnsavedChangesWarningDialog(),
) ??
false;
return shouldPop;
}
return true;
},
child: child,
);
}
}
@@ -0,0 +1,24 @@
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/generated/l10n/app_localizations.dart';
class UnsavedChangesWarningDialog extends StatelessWidget {
const UnsavedChangesWarningDialog({super.key});
@override
Widget build(BuildContext context) {
return AlertDialog(
title: Text("Discard changes?"),
content: Text(
"You have unsaved changes. Do you want to continue without saving? Your changes will be discarded.",
),
actions: [
DialogCancelButton(),
DialogConfirmButton(
label: S.of(context)!.continueLabel,
),
],
);
}
}
-45
View File
@@ -1,45 +0,0 @@
import 'package:flutter/material.dart';
import 'package:flutter_svg/flutter_svg.dart';
class EmptyState extends StatelessWidget {
final String title;
final String subtitle;
final Widget? bottomChild;
const EmptyState({
Key? key,
required this.title,
required this.subtitle,
this.bottomChild,
}) : super(key: key);
@override
Widget build(BuildContext context) {
final size = MediaQuery.of(context).size;
return Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
SizedBox(
height: size.height / 3,
width: size.width / 3,
child: SvgPicture.asset("assets/images/empty-state.svg"),
),
Column(
children: [
Text(
title,
style: Theme.of(context).textTheme.titleLarge?.copyWith(
fontWeight: FontWeight.bold,
),
),
Text(
subtitle,
style: Theme.of(context).textTheme.titleMedium,
),
],
),
if (bottomChild != null) ...[bottomChild!] else ...[]
],
);
}
}
@@ -1,430 +0,0 @@
import 'package:flutter/material.dart';
import 'package:flutter_form_builder/flutter_form_builder.dart';
import 'package:flutter_typeahead/flutter_typeahead.dart';
typedef SelectionToTextTransformer<T> = String Function(T suggestion);
/// Text field that auto-completes user input from a list of items
class FormBuilderTypeAhead<T> extends FormBuilderField<T> {
/// Called with the search pattern to get the search suggestions.
///
/// This callback must not be null. It is be called by the TypeAhead widget
/// and provided with the search pattern. It should return a [List](https://api.dartlang.org/stable/2.0.0/dart-core/List-class.html)
/// of suggestions either synchronously, or asynchronously (as the result of a
/// [Future](https://api.dartlang.org/stable/dart-async/Future-class.html)).
/// Typically, the list of suggestions should not contain more than 4 or 5
/// entries. These entries will then be provided to [itemBuilder] to display
/// the suggestions.
///
/// Example:
/// ```dart
/// suggestionsCallback: (pattern) async {
/// return await _getSuggestions(pattern);
/// }
/// ```
final SuggestionsCallback<T> suggestionsCallback;
/// Called when a suggestion is tapped.
///
/// This callback must not be null. It is called by the TypeAhead widget and
/// provided with the value of the tapped suggestion.
///
/// For example, you might want to navigate to a specific view when the user
/// tabs a suggestion:
/// ```dart
/// onSuggestionSelected: (suggestion) {
/// Navigator.of(context).push(MaterialPageRoute(
/// builder: (context) => SearchResult(
/// searchItem: suggestion
/// )
/// ));
/// }
/// ```
///
/// Or to set the value of the text field:
/// ```dart
/// onSuggestionSelected: (suggestion) {
/// _controller.text = suggestion['name'];
/// }
/// ```
final SuggestionSelectionCallback<T>? onSuggestionSelected;
/// Called for each suggestion returned by [suggestionsCallback] to build the
/// corresponding widget.
///
/// This callback must not be null. It is called by the TypeAhead widget for
/// each suggestion, and expected to build a widget to display this
/// suggestion's info. For example:
///
/// ```dart
/// itemBuilder: (context, suggestion) {
/// return ListTile(
/// title: Text(suggestion['name']),
/// subtitle: Text('USD' + suggestion['price'].toString())
/// );
/// }
/// ```
final ItemBuilder<T> itemBuilder;
/// The decoration of the material sheet that contains the suggestions.
///
/// If null, default decoration with an elevation of 4.0 is used
final SuggestionsBoxDecoration suggestionsBoxDecoration;
/// Used to control the `_SuggestionsBox`. Allows manual control to
/// open, close, toggle, or resize the `_SuggestionsBox`.
final SuggestionsBoxController? suggestionsBoxController;
/// The duration to wait after the user stops typing before calling
/// [suggestionsCallback]
///
/// This is useful, because, if not set, a request for suggestions will be
/// sent for every character that the user types.
///
/// This duration is set by default to 300 milliseconds
final Duration debounceDuration;
/// Called when waiting for [suggestionsCallback] to return.
///
/// It is expected to return a widget to display while waiting.
/// For example:
/// ```dart
/// (BuildContext context) {
/// return Text('Loading...');
/// }
/// ```
///
/// If not specified, a [CircularProgressIndicator](https://docs.flutter.io/flutter/material/CircularProgressIndicator-class.html) is shown
final WidgetBuilder? loadingBuilder;
/// Called when [suggestionsCallback] returns an empty array.
///
/// It is expected to return a widget to display when no suggestions are
/// available.
/// For example:
/// ```dart
/// (BuildContext context) {
/// return Text('No Items Found!');
/// }
/// ```
///
/// If not specified, a simple text is shown
final WidgetBuilder? noItemsFoundBuilder;
/// Called when [suggestionsCallback] throws an exception.
///
/// It is called with the error object, and expected to return a widget to
/// display when an exception is thrown
/// For example:
/// ```dart
/// (BuildContext context, error) {
/// return Text('$error');
/// }
/// ```
///
/// If not specified, the error is shown in [ThemeData.errorColor](https://docs.flutter.io/flutter/material/ThemeData/errorColor.html)
final ErrorBuilder? errorBuilder;
/// Called to display animations when [suggestionsCallback] returns suggestions
///
/// It is provided with the suggestions box instance and the animation
/// controller, and expected to return some animation that uses the controller
/// to display the suggestion box.
///
/// For example:
/// ```dart
/// transitionBuilder: (context, suggestionsBox, animationController) {
/// return FadeTransition(
/// child: suggestionsBox,
/// opacity: CurvedAnimation(
/// parent: animationController,
/// curve: Curves.fastOutSlowIn
/// ),
/// );
/// }
/// ```
/// This argument is best used with [animationDuration] and [animationStart]
/// to fully control the animation.
///
/// To fully remove the animation, just return `suggestionsBox`
///
/// If not specified, a [SizeTransition](https://docs.flutter.io/flutter/widgets/SizeTransition-class.html) is shown.
final AnimationTransitionBuilder? transitionBuilder;
/// The duration that [transitionBuilder] animation takes.
///
/// This argument is best used with [transitionBuilder] and [animationStart]
/// to fully control the animation.
///
/// Defaults to 500 milliseconds.
final Duration animationDuration;
/// Determine the [SuggestionBox]'s direction.
///
/// If [AxisDirection.down], the [SuggestionBox] will be below the [TextField]
/// and the [_SuggestionsList] will grow **down**.
///
/// If [AxisDirection.up], the [SuggestionBox] will be above the [TextField]
/// and the [_SuggestionsList] will grow **up**.
///
/// [AxisDirection.left] and [AxisDirection.right] are not allowed.
final AxisDirection direction;
/// The value at which the [transitionBuilder] animation starts.
///
/// This argument is best used with [transitionBuilder] and [animationDuration]
/// to fully control the animation.
///
/// Defaults to 0.25.
final double animationStart;
/// The configuration of the [TextField](https://docs.flutter.io/flutter/material/TextField-class.html)
/// that the TypeAhead widget displays
final TextFieldConfiguration textFieldConfiguration;
/// How far below the text field should the suggestions box be
///
/// Defaults to 5.0
final double suggestionsBoxVerticalOffset;
/// If set to true, suggestions will be fetched immediately when the field is
/// added to the view.
///
/// But the suggestions box will only be shown when the field receives focus.
/// To make the field receive focus immediately, you can set the `autofocus`
/// property in the [textFieldConfiguration] to true
///
/// Defaults to false
final bool getImmediateSuggestions;
/// If set to true, no loading box will be shown while suggestions are
/// being fetched. [loadingBuilder] will also be ignored.
///
/// Defaults to false.
final bool hideOnLoading;
/// If set to true, nothing will be shown if there are no results.
/// [noItemsFoundBuilder] will also be ignored.
///
/// Defaults to false.
final bool hideOnEmpty;
/// If set to true, nothing will be shown if there is an error.
/// [errorBuilder] will also be ignored.
///
/// Defaults to false.
final bool hideOnError;
/// If set to false, the suggestions box will stay opened after
/// the keyboard is closed.
///
/// Defaults to true.
final bool hideSuggestionsOnKeyboardHide;
/// If set to false, the suggestions box will show a circular
/// progress indicator when retrieving suggestions.
///
/// Defaults to true.
final bool keepSuggestionsOnLoading;
/// If set to true, the suggestions box will remain opened even after
/// selecting a suggestion.
///
/// Note that if this is enabled, the only way
/// to close the suggestions box is either manually via the
/// `SuggestionsBoxController` or when the user closes the software
/// keyboard if `hideSuggestionsOnKeyboardHide` is set to true. Users
/// with a physical keyboard will be unable to close the
/// box without a manual way via `SuggestionsBoxController`.
///
/// Defaults to false.
final bool keepSuggestionsOnSuggestionSelected;
/// If set to true, in the case where the suggestions box has less than
/// _SuggestionsBoxController.minOverlaySpace to grow in the desired [direction], the direction axis
/// will be temporarily flipped if there's more room available in the opposite
/// direction.
///
/// Defaults to false
final bool autoFlipDirection;
final SelectionToTextTransformer<T>? selectionToTextTransformer;
/// Controls the text being edited.
///
/// If null, this widget will create its own [TextEditingController].
final TextEditingController? controller;
final bool hideKeyboard;
final ScrollController? scrollController;
/// Creates text field that auto-completes user input from a list of items
FormBuilderTypeAhead({
Key? key,
//From Super
AutovalidateMode autovalidateMode = AutovalidateMode.disabled,
bool enabled = true,
FocusNode? focusNode,
FormFieldSetter<T>? onSaved,
FormFieldValidator<T>? validator,
InputDecoration decoration = const InputDecoration(),
required String name,
required this.itemBuilder,
required this.suggestionsCallback,
T? initialValue,
ValueChanged<T?>? onChanged,
ValueTransformer<T?>? valueTransformer,
VoidCallback? onReset,
this.animationDuration = const Duration(milliseconds: 500),
this.animationStart = 0.25,
this.autoFlipDirection = false,
this.controller,
this.debounceDuration = const Duration(milliseconds: 300),
this.direction = AxisDirection.down,
this.errorBuilder,
this.getImmediateSuggestions = false,
this.hideKeyboard = false,
this.hideOnEmpty = false,
this.hideOnError = false,
this.hideOnLoading = false,
this.hideSuggestionsOnKeyboardHide = true,
this.keepSuggestionsOnLoading = true,
this.keepSuggestionsOnSuggestionSelected = false,
this.loadingBuilder,
this.noItemsFoundBuilder,
this.onSuggestionSelected,
this.scrollController,
this.selectionToTextTransformer,
this.suggestionsBoxController,
this.suggestionsBoxDecoration = const SuggestionsBoxDecoration(),
this.suggestionsBoxVerticalOffset = 5.0,
this.textFieldConfiguration = const TextFieldConfiguration(),
this.transitionBuilder,
}) : assert(T == String || selectionToTextTransformer != null),
super(
key: key,
initialValue: initialValue,
name: name,
validator: validator,
valueTransformer: valueTransformer,
onChanged: onChanged,
autovalidateMode: autovalidateMode,
onSaved: onSaved,
enabled: enabled,
onReset: onReset,
decoration: decoration,
focusNode: focusNode,
builder: (FormFieldState<T?> field) {
final state = field as FormBuilderTypeAheadState<T>;
final theme = Theme.of(state.context);
return TypeAheadField<T>(
textFieldConfiguration: textFieldConfiguration.copyWith(
enabled: state.enabled,
controller: state._typeAheadController,
style: state.enabled
? textFieldConfiguration.style
: theme.textTheme.titleMedium!.copyWith(
color: theme.disabledColor,
),
focusNode: state.effectiveFocusNode,
decoration: state.decoration,
),
// TODO HACK to satisfy strictness
suggestionsCallback: suggestionsCallback,
itemBuilder: itemBuilder,
transitionBuilder: (context, suggestionsBox, controller) =>
suggestionsBox,
onSuggestionSelected: (T suggestion) {
state.didChange(suggestion);
onSuggestionSelected?.call(suggestion);
},
getImmediateSuggestions: getImmediateSuggestions,
errorBuilder: errorBuilder,
noItemsFoundBuilder: noItemsFoundBuilder,
loadingBuilder: loadingBuilder,
debounceDuration: debounceDuration,
suggestionsBoxDecoration: suggestionsBoxDecoration,
suggestionsBoxVerticalOffset: suggestionsBoxVerticalOffset,
animationDuration: animationDuration,
animationStart: animationStart,
direction: direction,
hideOnLoading: hideOnLoading,
hideOnEmpty: hideOnEmpty,
hideOnError: hideOnError,
hideSuggestionsOnKeyboardHide: hideSuggestionsOnKeyboardHide,
keepSuggestionsOnLoading: keepSuggestionsOnLoading,
autoFlipDirection: autoFlipDirection,
suggestionsBoxController: suggestionsBoxController,
keepSuggestionsOnSuggestionSelected:
keepSuggestionsOnSuggestionSelected,
hideKeyboard: hideKeyboard,
scrollController: scrollController,
);
},
);
@override
FormBuilderTypeAheadState<T> createState() => FormBuilderTypeAheadState<T>();
}
class FormBuilderTypeAheadState<T>
extends FormBuilderFieldState<FormBuilderTypeAhead<T>, T> {
late TextEditingController _typeAheadController;
@override
void initState() {
super.initState();
_typeAheadController = widget.controller ??
TextEditingController(text: _getTextString(initialValue));
// _typeAheadController.addListener(_handleControllerChanged);
}
// void _handleControllerChanged() {
// Suppress changes that originated from within this class.
//
// In the case where a controller has been passed in to this widget, we
// register this change listener. In these cases, we'll also receive change
// notifications for changes originating from within this class -- for
// example, the reset() method. In such cases, the FormField value will
// already have been set.
// if (_typeAheadController.text != value) {
// didChange(_typeAheadController.text as T);
// }
// }
@override
void didChange(T? value) {
super.didChange(value);
var text = _getTextString(value);
if (_typeAheadController.text != text) {
_typeAheadController.text = text;
}
}
@override
void dispose() {
// Dispose the _typeAheadController when initState created it
super.dispose();
_typeAheadController.dispose();
}
@override
void reset() {
super.reset();
_typeAheadController.text = _getTextString(initialValue);
}
String _getTextString(T? value) {
var text = value == null
? ''
: widget.selectionToTextTransformer != null
? widget.selectionToTextTransformer!(value)
: value.toString();
return text;
}
}
+35
View File
@@ -0,0 +1,35 @@
import 'dart:async';
import 'package:flutter/material.dart';
class FutureOrBuilder<T> extends StatelessWidget {
final FutureOr<T>? futureOrValue;
final T? initialData;
final AsyncWidgetBuilder<T> builder;
const FutureOrBuilder({
super.key,
FutureOr<T>? 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,
);
}
}
}
-288
View File
@@ -1,288 +0,0 @@
// MIT License
//
// Copyright (c) 2019 Simon Lightfoot
//
// Permission is hereby granted, free of charge, to any person obtaining a copy
// of this software and associated documentation files (the "Software"), to deal
// in the Software without restriction, including without limitation the rights
// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
// copies of the Software, and to permit persons to whom the Software is
// furnished to do so, subject to the following conditions:
//
// The above copyright notice and this permission notice shall be included in all
// copies or substantial portions of the Software.
//
// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
// SOFTWARE.
//
import 'dart:async';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
typedef ChipsInputSuggestions<T> = Future<List<T>> Function(String query);
typedef ChipSelected<T> = void Function(T data, bool selected);
typedef ChipsBuilder<T> = Widget Function(
BuildContext context, ChipsInputState<T> state, T data);
class ChipsInput<T> extends StatefulWidget {
const ChipsInput({
super.key,
this.decoration = const InputDecoration(),
required this.chipBuilder,
required this.suggestionBuilder,
required this.findSuggestions,
required this.onChanged,
this.onChipTapped,
});
final InputDecoration decoration;
final ChipsInputSuggestions<T> findSuggestions;
final ValueChanged<List<T>> onChanged;
final ValueChanged<T>? onChipTapped;
final ChipsBuilder<T> chipBuilder;
final ChipsBuilder<T> suggestionBuilder;
@override
ChipsInputState<T> createState() => ChipsInputState<T>();
}
class ChipsInputState<T> extends State<ChipsInput<T>> {
static const kObjectReplacementChar = 0xFFFC;
Set<T> _chips = {};
List<T> _suggestions = [];
int _searchId = 0;
FocusNode _focusNode = FocusNode();
TextEditingValue _value = const TextEditingValue();
TextInputConnection? _connection;
String get text {
return String.fromCharCodes(
_value.text.codeUnits.where((ch) => ch != kObjectReplacementChar),
);
}
TextEditingValue get currentTextEditingValue => _value;
bool get _hasInputConnection =>
_connection != null && (_connection?.attached ?? false);
void requestKeyboard() {
if (_focusNode.hasFocus) {
_openInputConnection();
} else {
FocusScope.of(context).requestFocus(_focusNode);
}
}
void selectSuggestion(T data) {
setState(() {
_chips.add(data);
_updateTextInputState();
_suggestions = [];
});
widget.onChanged(_chips.toList(growable: false));
}
void deleteChip(T data) {
setState(() {
_chips.remove(data);
_updateTextInputState();
});
widget.onChanged(_chips.toList(growable: false));
}
@override
void initState() {
super.initState();
_focusNode = FocusNode();
_focusNode.addListener(_onFocusChanged);
}
void _onFocusChanged() {
if (_focusNode.hasFocus) {
_openInputConnection();
} else {
_closeInputConnectionIfNeeded();
}
setState(() {
// rebuild so that _TextCursor is hidden.
});
}
@override
void dispose() {
_focusNode.dispose();
_closeInputConnectionIfNeeded();
super.dispose();
}
void _openInputConnection() {
if (!_hasInputConnection) {
_connection?.setEditingState(_value);
}
_connection?.show();
}
void _closeInputConnectionIfNeeded() {
if (_hasInputConnection) {
_connection?.close();
_connection = null;
}
}
@override
Widget build(BuildContext context) {
var chipsChildren = _chips
.map<Widget>(
(data) => widget.chipBuilder(context, this, data),
)
.toList();
final theme = Theme.of(context);
chipsChildren.add(
SizedBox(
height: 32.0,
child: Row(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: <Widget>[
Text(
text,
style: theme.textTheme.bodyLarge?.copyWith(
height: 1.5,
),
),
_TextCaret(
resumed: _focusNode.hasFocus,
),
],
),
),
);
return Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
//mainAxisSize: MainAxisSize.min,
children: <Widget>[
GestureDetector(
behavior: HitTestBehavior.opaque,
onTap: requestKeyboard,
child: InputDecorator(
decoration: widget.decoration,
isFocused: _focusNode.hasFocus,
isEmpty: _value.text.isEmpty,
child: Wrap(
children: chipsChildren,
spacing: 4.0,
runSpacing: 4.0,
),
),
),
Expanded(
child: ListView.builder(
itemCount: _suggestions.length,
itemBuilder: (BuildContext context, int index) {
return widget.suggestionBuilder(
context, this, _suggestions[index]);
},
),
),
],
);
}
void updateEditingValue(TextEditingValue value) {
final oldCount = _countReplacements(_value);
final newCount = _countReplacements(value);
setState(() {
if (newCount < oldCount) {
_chips = Set.from(_chips.take(newCount));
}
_value = value;
});
_onSearchChanged(text);
}
int _countReplacements(TextEditingValue value) {
return value.text.codeUnits
.where((ch) => ch == kObjectReplacementChar)
.length;
}
void _updateTextInputState() {
final text =
String.fromCharCodes(_chips.map((_) => kObjectReplacementChar));
_value = TextEditingValue(
text: text,
selection: TextSelection.collapsed(offset: text.length),
composing: TextRange(start: 0, end: text.length),
);
_connection?.setEditingState(_value);
}
void _onSearchChanged(String value) async {
final localId = ++_searchId;
final results = await widget.findSuggestions(value);
if (_searchId == localId && mounted) {
setState(() => _suggestions = results
.where((profile) => !_chips.contains(profile))
.toList(growable: false));
}
}
}
class _TextCaret extends StatefulWidget {
const _TextCaret({
this.resumed = false,
});
final bool resumed;
@override
_TextCursorState createState() => _TextCursorState();
}
class _TextCursorState extends State<_TextCaret>
with SingleTickerProviderStateMixin {
bool _displayed = false;
late Timer _timer;
@override
void initState() {
super.initState();
}
void _onTimer(Timer timer) {
setState(() => _displayed = !_displayed);
}
@override
void dispose() {
_timer.cancel();
super.dispose();
}
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
return FractionallySizedBox(
heightFactor: 0.7,
child: Opacity(
opacity: _displayed && widget.resumed ? 1.0 : 0.0,
child: Container(
width: 2.0,
color: theme.primaryColor,
),
),
);
}
}
+108 -34
View File
@@ -1,16 +1,19 @@
import 'package:flutter/cupertino.dart';
import 'package:flutter/gestures.dart'; import 'package:flutter/gestures.dart';
import 'package:flutter/material.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:flutter_svg/flutter_svg.dart';
import 'package:paperless_api/paperless_api.dart';
import 'package:paperless_mobile/constants.dart'; import 'package:paperless_mobile/constants.dart';
import 'package:paperless_mobile/core/widgets/paperless_logo.dart'; import 'package:paperless_mobile/core/widgets/paperless_logo.dart';
import 'package:paperless_mobile/extensions/flutter_extensions.dart'; import 'package:paperless_mobile/extensions/flutter_extensions.dart';
import 'package:paperless_mobile/features/home/view/model/api_version.dart'; import 'package:paperless_mobile/features/documents/cubit/documents_cubit.dart';
import 'package:paperless_mobile/features/settings/view/settings_page.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/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:provider/provider.dart';
import 'package:url_launcher/url_launcher_string.dart'; import 'package:url_launcher/url_launcher_string.dart';
class AppDrawer extends StatelessWidget { class AppDrawer extends StatelessWidget {
@@ -21,6 +24,7 @@ class AppDrawer extends StatelessWidget {
return SafeArea( return SafeArea(
child: Drawer( child: Drawer(
child: Column( child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
Row( Row(
children: [ children: [
@@ -60,56 +64,126 @@ class AppDrawer extends StatelessWidget {
), ),
ListTile( ListTile(
dense: true, dense: true,
leading: Padding( leading: const Icon(Icons.favorite_outline),
padding: const EdgeInsets.only(left: 3), title: Text(S.of(context)!.donate),
child: SvgPicture.asset( onTap: () {
'assets/images/bmc-logo.svg', showDialog(
width: 24, context: context,
height: 24, builder: (context) => AlertDialog(
), icon: const Icon(Icons.favorite),
title: Text(S.of(context)!.donate),
content: Text(
S.of(context)!.donationDialogContent,
),
actions: const [
Text("~ Anton"),
],
),
);
},
),
ListTile(
dense: true,
leading: SvgPicture.asset(
"assets/images/github-mark.svg",
color: Theme.of(context).colorScheme.onBackground,
height: 24,
width: 24,
), ),
title: Row( title: Text(S.of(context)!.sourceCode),
mainAxisAlignment: MainAxisAlignment.spaceBetween, trailing: const Icon(
children: [ Icons.open_in_new,
Text(S.of(context)!.donateCoffee), size: 16,
const Icon(
Icons.open_in_new,
size: 16,
)
],
), ),
onTap: () { onTap: () {
launchUrlString( launchUrlString(
"https://www.buymeacoffee.com/astubenbord", "https://github.com/astubenbord/paperless-mobile",
mode: LaunchMode.externalApplication, mode: LaunchMode.externalApplication,
); );
}, },
), ),
Consumer<ConsumptionChangeNotifier>(
builder: (context, value, child) {
final files = value.pendingFiles;
final child = ListTile(
dense: true,
leading: const Icon(Icons.drive_folder_upload_outlined),
title: const Text("Pending Files"),
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( ListTile(
dense: true, dense: true,
leading: const Icon(Icons.settings_outlined), leading: const Icon(Icons.settings_outlined),
title: Text( title: Text(
S.of(context)!.settings, S.of(context)!.settings,
), ),
onTap: () => Navigator.of(context).push( onTap: () => SettingsRoute().push(context),
MaterialPageRoute(
builder: (_) => MultiProvider(
providers: [
Provider.value(
value: context.read<PaperlessServerStatsApi>()),
Provider.value(value: context.read<ApiVersion>()),
],
child: const SettingsPage(),
),
),
),
), ),
const Divider(),
Text(
S.of(context)!.views,
textAlign: TextAlign.left,
style: Theme.of(context).textTheme.labelLarge,
).padded(16),
_buildSavedViews(),
], ],
), ),
), ),
); );
} }
Widget _buildSavedViews() {
return BlocBuilder<SavedViewCubit, SavedViewState>(
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<DocumentsCubit>()
.updateFilter(filter: view.toDocumentFilter());
DocumentsRoute().go(context);
},
);
},
itemCount: sidebarViews.length,
),
);
},
error: () => Text(S.of(context)!.couldNotLoadSavedViews),
);
});
}
void _showAboutDialog(BuildContext context) { void _showAboutDialog(BuildContext context) {
final theme = Theme.of(context); final theme = Theme.of(context);
final colorScheme = theme.colorScheme; final colorScheme = theme.colorScheme;
@@ -1,104 +0,0 @@
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:flutter_form_builder/flutter_form_builder.dart';
import 'package:paperless_api/paperless_api.dart';
import 'package:paperless_mobile/core/widgets/dialog_utils/dialog_cancel_button.dart';
import 'package:paperless_mobile/extensions/flutter_extensions.dart';
import 'package:paperless_mobile/features/document_bulk_action/cubit/document_bulk_action_cubit.dart';
import 'package:paperless_mobile/features/labels/view/widgets/label_form_field.dart';
import 'package:paperless_mobile/generated/l10n/app_localizations.dart';
typedef LabelOptionsSelector<T extends Label> = Map<int, T> Function(
DocumentBulkActionState state);
class BulkEditLabelBottomSheet<T extends Label> extends StatefulWidget {
final String title;
final String formFieldLabel;
final Widget formFieldPrefixIcon;
final LabelOptionsSelector<T> availableOptionsSelector;
final void Function(int? selectedId) onSubmit;
final int? initialValue;
final bool canCreateNewLabel;
const BulkEditLabelBottomSheet({
super.key,
required this.title,
required this.formFieldLabel,
required this.formFieldPrefixIcon,
required this.availableOptionsSelector,
required this.onSubmit,
this.initialValue,
required this.canCreateNewLabel,
});
@override
State<BulkEditLabelBottomSheet<T>> createState() =>
_BulkEditLabelBottomSheetState<T>();
}
class _BulkEditLabelBottomSheetState<T extends Label>
extends State<BulkEditLabelBottomSheet<T>> {
final _formKey = GlobalKey<FormBuilderState>();
@override
Widget build(BuildContext context) {
return Padding(
padding:
EdgeInsets.only(bottom: MediaQuery.of(context).viewInsets.bottom),
child: BlocBuilder<DocumentBulkActionCubit, DocumentBulkActionState>(
builder: (context, state) {
return Padding(
padding: const EdgeInsets.all(16.0),
child: SingleChildScrollView(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
Text(
widget.title,
style: Theme.of(context).textTheme.titleLarge,
).paddedOnly(bottom: 24),
FormBuilder(
key: _formKey,
child: LabelFormField<T>(
initialValue: widget.initialValue != null
? IdQueryParameter.fromId(widget.initialValue!)
: const IdQueryParameter.unset(),
canCreateNewLabel: widget.canCreateNewLabel,
name: "labelFormField",
options: widget.availableOptionsSelector(state),
labelText: widget.formFieldLabel,
prefixIcon: widget.formFieldPrefixIcon,
allowSelectUnassigned: true,
),
),
const SizedBox(height: 8),
Row(
mainAxisAlignment: MainAxisAlignment.end,
children: [
const DialogCancelButton(),
const SizedBox(width: 16),
FilledButton(
onPressed: () {
if (_formKey.currentState?.saveAndValidate() ??
false) {
final value = _formKey.currentState
?.getRawValue('labelFormField')
as IdQueryParameter?;
widget.onSubmit(value?.maybeWhen(
fromId: (id) => id, orElse: () => null));
}
},
child: Text(S.of(context)!.apply),
),
],
).padded(8),
],
),
),
);
},
),
);
}
}
@@ -1,5 +1,6 @@
import 'package:collection/collection.dart'; import 'package:collection/collection.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:go_router/go_router.dart';
import 'package:paperless_api/paperless_api.dart'; import 'package:paperless_api/paperless_api.dart';
import 'package:paperless_mobile/core/widgets/form_fields/fullscreen_selection_form.dart'; import 'package:paperless_mobile/core/widgets/form_fields/fullscreen_selection_form.dart';
import 'package:paperless_mobile/extensions/dart_extensions.dart'; import 'package:paperless_mobile/extensions/dart_extensions.dart';
@@ -86,6 +87,7 @@ class _FullscreenBulkEditLabelPageState<T extends Label>
selectionCount: _labels.length, selectionCount: _labels.length,
floatingActionButton: !hideFab floatingActionButton: !hideFab
? FloatingActionButton.extended( ? FloatingActionButton.extended(
heroTag: "fab_fullscreen_bulk_edit_label",
onPressed: _onSubmit, onPressed: _onSubmit,
label: Text(S.of(context)!.apply), label: Text(S.of(context)!.apply),
icon: const Icon(Icons.done), icon: const Icon(Icons.done),
@@ -122,7 +124,7 @@ class _FullscreenBulkEditLabelPageState<T extends Label>
void _onSubmit() async { void _onSubmit() async {
if (_selection == null) { if (_selection == null) {
Navigator.pop(context); context.pop();
} else { } else {
bool shouldPerformAction; bool shouldPerformAction;
if (_selection!.label == null) { if (_selection!.label == null) {
@@ -148,7 +150,7 @@ class _FullscreenBulkEditLabelPageState<T extends Label>
} }
if (shouldPerformAction) { if (shouldPerformAction) {
widget.onSubmit(_selection!.label); widget.onSubmit(_selection!.label);
Navigator.pop(context); context.pop();
} }
} }
} }
@@ -1,6 +1,7 @@
import 'package:collection/collection.dart'; import 'package:collection/collection.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:go_router/go_router.dart';
import 'package:paperless_api/paperless_api.dart'; import 'package:paperless_api/paperless_api.dart';
import 'package:paperless_mobile/core/widgets/form_fields/fullscreen_selection_form.dart'; import 'package:paperless_mobile/core/widgets/form_fields/fullscreen_selection_form.dart';
import 'package:paperless_mobile/extensions/dart_extensions.dart'; import 'package:paperless_mobile/extensions/dart_extensions.dart';
@@ -74,6 +75,7 @@ class _FullscreenBulkEditTagsWidgetState
controller: _controller, controller: _controller,
floatingActionButton: _addTags.isNotEmpty || _removeTags.isNotEmpty floatingActionButton: _addTags.isNotEmpty || _removeTags.isNotEmpty
? FloatingActionButton.extended( ? FloatingActionButton.extended(
heroTag: "fab_fullscreen_bulk_edit_tags",
label: Text(S.of(context)!.apply), label: Text(S.of(context)!.apply),
icon: const Icon(Icons.done), icon: const Icon(Icons.done),
onPressed: _submit, onPressed: _submit,
@@ -173,7 +175,7 @@ class _FullscreenBulkEditTagsWidgetState
removeTagIds: _removeTags, removeTagIds: _removeTags,
addTagIds: _addTags, addTagIds: _addTags,
); );
Navigator.pop(context); context.pop();
} }
} }
} }
@@ -1,30 +0,0 @@
// import 'package:flutter/material.dart';
// import 'package:flutter/src/widgets/framework.dart';
// import 'package:flutter/src/widgets/placeholder.dart';
// class LabelBulkSelectionWidget extends StatelessWidget {
// final int labelId;
// final String title;
// final bool selected;
// final bool excluded;
// final Widget Function(int id) leadingWidgetBuilder;
// final void Function(int id) onSelected;
// final void Function(int id) onUnselected;
// final void Function(int id) onRemoved;
// const LabelBulkSelectionWidget({
// super.key,
// required this.labelId,
// required this.title,
// required this.leadingWidgetBuilder,
// required this.onSelected,
// required this.onUnselected,
// required this.onRemoved,
// });
// @override
// Widget build(BuildContext context) {
// return ListTile(
// title: Text(title),
// );
// }
// }
@@ -8,13 +8,12 @@ import 'package:open_filex/open_filex.dart';
import 'package:paperless_api/paperless_api.dart'; import 'package:paperless_api/paperless_api.dart';
import 'package:paperless_mobile/core/notifier/document_changed_notifier.dart'; import 'package:paperless_mobile/core/notifier/document_changed_notifier.dart';
import 'package:paperless_mobile/core/repository/label_repository.dart'; import 'package:paperless_mobile/core/repository/label_repository.dart';
import 'package:paperless_mobile/core/service/file_description.dart';
import 'package:paperless_mobile/core/service/file_service.dart'; import 'package:paperless_mobile/core/service/file_service.dart';
import 'package:paperless_mobile/features/notifications/services/local_notification_service.dart'; import 'package:paperless_mobile/features/notifications/services/local_notification_service.dart';
import 'package:printing/printing.dart'; import 'package:printing/printing.dart';
import 'package:share_plus/share_plus.dart'; import 'package:share_plus/share_plus.dart';
import 'package:cross_file/cross_file.dart'; import 'package:cross_file/cross_file.dart';
import 'package:path/path.dart' as p;
part 'document_details_cubit.freezed.dart'; part 'document_details_cubit.freezed.dart';
part 'document_details_state.dart'; part 'document_details_state.dart';
@@ -45,7 +44,6 @@ class DocumentDetailsCubit extends Cubit<DocumentDetailsState> {
), ),
), ),
); );
loadSuggestions();
loadMetaData(); loadMetaData();
} }
@@ -54,13 +52,6 @@ class DocumentDetailsCubit extends Cubit<DocumentDetailsState> {
_notifier.notifyDeleted(document); _notifier.notifyDeleted(document);
} }
Future<void> loadSuggestions() async {
final suggestions = await _api.findSuggestions(state.document);
if (!isClosed) {
emit(state.copyWith(suggestions: suggestions));
}
}
Future<void> loadMetaData() async { Future<void> loadMetaData() async {
final metaData = await _api.getMetaData(state.document); final metaData = await _api.getMetaData(state.document);
if (!isClosed) { if (!isClosed) {
@@ -101,11 +92,9 @@ class DocumentDetailsCubit extends Cubit<DocumentDetailsState> {
if (state.metaData == null) { if (state.metaData == null) {
await loadMetaData(); await loadMetaData();
} }
final desc = FileDescription.fromPath( final filePath = state.metaData!.mediaFilename.replaceAll("/", " ");
state.metaData!.mediaFilename.replaceAll("/", " "),
);
final fileName = "${desc.filename}.pdf"; final fileName = "${p.basenameWithoutExtension(filePath)}.pdf";
final file = File("${cacheDir.path}/$fileName"); final file = File("${cacheDir.path}/$fileName");
if (!file.existsSync()) { if (!file.existsSync()) {
@@ -128,51 +117,63 @@ class DocumentDetailsCubit extends Cubit<DocumentDetailsState> {
Future<void> downloadDocument({ Future<void> downloadDocument({
bool downloadOriginal = false, bool downloadOriginal = false,
required String locale, required String locale,
required String userId,
}) async { }) async {
if (state.metaData == null) { if (state.metaData == null) {
await loadMetaData(); await loadMetaData();
} }
String filePath = _buildDownloadFilePath( String targetPath = _buildDownloadFilePath(
downloadOriginal, downloadOriginal,
await FileService.downloadsDirectory, await FileService.downloadsDirectory,
); );
final desc = FileDescription.fromPath(
state.metaData!.mediaFilename if (!await File(targetPath).exists()) {
.replaceAll("/", " "), // Flatten directory structure await File(targetPath).create();
);
if (!File(filePath).existsSync()) {
File(filePath).createSync();
} else { } else {
return _notificationService.notifyFileDownload( await _notificationService.notifyFileDownload(
document: state.document, document: state.document,
filename: "${desc.filename}.${desc.extension}", filename: p.basename(targetPath),
filePath: filePath, filePath: targetPath,
finished: true, finished: true,
locale: locale, locale: locale,
userId: userId,
); );
} }
await _notificationService.notifyFileDownload( // await _notificationService.notifyFileDownload(
document: state.document, // document: state.document,
filename: "${desc.filename}.${desc.extension}", // filename: p.basename(targetPath),
filePath: filePath, // filePath: targetPath,
finished: false, // finished: false,
locale: locale, // locale: locale,
); // userId: userId,
// );
await _api.downloadToFile( await _api.downloadToFile(
state.document, state.document,
filePath, targetPath,
original: downloadOriginal, original: downloadOriginal,
onProgressChanged: (progress) {
_notificationService.notifyFileDownload(
document: state.document,
filename: p.basename(targetPath),
filePath: targetPath,
finished: true,
locale: locale,
userId: userId,
progress: progress,
);
},
); );
await _notificationService.notifyFileDownload( await _notificationService.notifyFileDownload(
document: state.document, document: state.document,
filename: "${desc.filename}.${desc.extension}", filename: p.basename(targetPath),
filePath: filePath, filePath: targetPath,
finished: true, finished: true,
locale: locale, locale: locale,
userId: userId,
); );
debugPrint("Downloaded file to $filePath"); debugPrint("Downloaded file to $targetPath");
} }
Future<void> shareDocument({bool shareOriginal = false}) async { Future<void> shareDocument({bool shareOriginal = false}) async {
@@ -223,12 +224,9 @@ class DocumentDetailsCubit extends Cubit<DocumentDetailsState> {
} }
String _buildDownloadFilePath(bool original, Directory dir) { String _buildDownloadFilePath(bool original, Directory dir) {
final description = FileDescription.fromPath( final normalizedPath = state.metaData!.mediaFilename.replaceAll("/", " ");
state.metaData!.mediaFilename final extension = original ? p.extension(normalizedPath) : '.pdf';
.replaceAll("/", " "), // Flatten directory structure return "${dir.path}/${p.basenameWithoutExtension(normalizedPath)}$extension";
);
final extension = original ? description.extension : 'pdf';
return "${dir.path}/${description.filename}.$extension";
} }
@override @override
@@ -7,7 +7,6 @@ class DocumentDetailsState with _$DocumentDetailsState {
DocumentMetaData? metaData, DocumentMetaData? metaData,
@Default(false) bool isFullContentLoaded, @Default(false) bool isFullContentLoaded,
String? fullContent, String? fullContent,
FieldSuggestions? suggestions,
@Default({}) Map<int, Correspondent> correspondents, @Default({}) Map<int, Correspondent> correspondents,
@Default({}) Map<int, DocumentType> documentTypes, @Default({}) Map<int, DocumentType> documentTypes,
@Default({}) Map<int, Tag> tags, @Default({}) Map<int, Tag> tags,
@@ -1,5 +1,6 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:go_router/go_router.dart';
import 'package:open_filex/open_filex.dart'; import 'package:open_filex/open_filex.dart';
import 'package:paperless_api/paperless_api.dart'; import 'package:paperless_api/paperless_api.dart';
import 'package:paperless_mobile/core/bloc/connectivity_cubit.dart'; import 'package:paperless_mobile/core/bloc/connectivity_cubit.dart';
@@ -14,16 +15,14 @@ import 'package:paperless_mobile/features/document_details/view/widgets/document
import 'package:paperless_mobile/features/document_details/view/widgets/document_overview_widget.dart'; import 'package:paperless_mobile/features/document_details/view/widgets/document_overview_widget.dart';
import 'package:paperless_mobile/features/document_details/view/widgets/document_permissions_widget.dart'; import 'package:paperless_mobile/features/document_details/view/widgets/document_permissions_widget.dart';
import 'package:paperless_mobile/features/document_details/view/widgets/document_share_button.dart'; import 'package:paperless_mobile/features/document_details/view/widgets/document_share_button.dart';
import 'package:paperless_mobile/features/document_edit/cubit/document_edit_cubit.dart';
import 'package:paperless_mobile/features/document_edit/view/document_edit_page.dart';
import 'package:paperless_mobile/features/documents/view/pages/document_view.dart';
import 'package:paperless_mobile/features/documents/view/widgets/delete_document_confirmation_dialog.dart'; import 'package:paperless_mobile/features/documents/view/widgets/delete_document_confirmation_dialog.dart';
import 'package:paperless_mobile/features/documents/view/widgets/document_preview.dart'; import 'package:paperless_mobile/features/documents/view/widgets/document_preview.dart';
import 'package:paperless_mobile/features/home/view/model/api_version.dart';
import 'package:paperless_mobile/features/similar_documents/cubit/similar_documents_cubit.dart'; import 'package:paperless_mobile/features/similar_documents/cubit/similar_documents_cubit.dart';
import 'package:paperless_mobile/features/similar_documents/view/similar_documents_view.dart'; import 'package:paperless_mobile/features/similar_documents/view/similar_documents_view.dart';
import 'package:paperless_mobile/generated/l10n/app_localizations.dart'; import 'package:paperless_mobile/generated/l10n/app_localizations.dart';
import 'package:paperless_mobile/helpers/connectivity_aware_action_wrapper.dart';
import 'package:paperless_mobile/helpers/message_helpers.dart'; import 'package:paperless_mobile/helpers/message_helpers.dart';
import 'package:paperless_mobile/routes/typed/branches/documents_route.dart';
class DocumentDetailsPage extends StatefulWidget { class DocumentDetailsPage extends StatefulWidget {
final bool isLabelClickable; final bool isLabelClickable;
@@ -46,9 +45,9 @@ class _DocumentDetailsPageState extends State<DocumentDetailsPage> {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final apiVersion = context.watch<ApiVersion>(); final hasMultiUserSupport =
context.watch<LocalUserAccount>().hasMultiUserSupport;
final tabLength = 4 + (apiVersion.hasMultiUserSupport ? 1 : 0); final tabLength = 4 + (hasMultiUserSupport && false ? 1 : 0);
return WillPopScope( return WillPopScope(
onWillPop: () async { onWillPop: () async {
Navigator.of(context) Navigator.of(context)
@@ -86,45 +85,52 @@ class _DocumentDetailsPageState extends State<DocumentDetailsPage> {
collapsedHeight: kToolbarHeight, collapsedHeight: kToolbarHeight,
expandedHeight: 250.0, expandedHeight: 250.0,
flexibleSpace: FlexibleSpaceBar( flexibleSpace: FlexibleSpaceBar(
background: Stack( background: BlocBuilder<DocumentDetailsCubit,
alignment: Alignment.topCenter, DocumentDetailsState>(
children: [ builder: (context, state) {
BlocBuilder<DocumentDetailsCubit, return Hero(
DocumentDetailsState>( tag: "thumb_${state.document.id}",
builder: (context, state) { child: GestureDetector(
return Positioned.fill( onTap: () {
child: DocumentPreview( DocumentPreviewRoute($extra: state.document)
document: state.document, .push(context);
fit: BoxFit.cover, },
), child: Stack(
); alignment: Alignment.topCenter,
}, children: [
), Positioned.fill(
Positioned.fill( child: DocumentPreview(
top: 0, enableHero: false,
child: DecoratedBox( document: state.document,
decoration: BoxDecoration( fit: BoxFit.cover,
gradient: LinearGradient( ),
colors: [ ),
Theme.of(context) Positioned.fill(
.colorScheme child: DecoratedBox(
.background decoration: BoxDecoration(
.withOpacity(0.8), gradient: LinearGradient(
Theme.of(context) stops: [0.2, 0.4],
.colorScheme colors: [
.background Theme.of(context)
.withOpacity(0.5), .colorScheme
Colors.transparent, .background
Colors.transparent, .withOpacity(0.6),
Colors.transparent, Theme.of(context)
], .colorScheme
begin: Alignment.topCenter, .background
end: Alignment.bottomCenter, .withOpacity(0.3),
), ],
begin: Alignment.topCenter,
end: Alignment.bottomCenter,
),
),
),
),
],
), ),
), ),
), );
], },
), ),
), ),
bottom: ColoredTabBar( bottom: ColoredTabBar(
@@ -171,7 +177,7 @@ class _DocumentDetailsPageState extends State<DocumentDetailsPage> {
), ),
), ),
), ),
if (apiVersion.hasMultiUserSupport) if (hasMultiUserSupport && false)
Tab( Tab(
child: Text( child: Text(
"Permissions", "Permissions",
@@ -195,6 +201,7 @@ class _DocumentDetailsPageState extends State<DocumentDetailsPage> {
context.read(), context.read(),
context.read(), context.read(),
context.read(), context.read(),
context.read(),
documentId: state.document.id, documentId: state.document.id,
), ),
child: Padding( child: Padding(
@@ -259,7 +266,7 @@ class _DocumentDetailsPageState extends State<DocumentDetailsPage> {
), ),
], ],
), ),
if (apiVersion.hasMultiUserSupport) if (hasMultiUserSupport && false)
CustomScrollView( CustomScrollView(
controller: _pagingScrollController, controller: _pagingScrollController,
slivers: [ slivers: [
@@ -286,8 +293,10 @@ class _DocumentDetailsPageState extends State<DocumentDetailsPage> {
} }
Widget _buildEditButton() { Widget _buildEditButton() {
final currentUser = context.watch<LocalUserAccount>();
bool canEdit = context.watchInternetConnection && bool canEdit = context.watchInternetConnection &&
LocalUserAccount.current.paperlessUser.canEditDocuments; currentUser.paperlessUser.canEditDocuments;
if (!canEdit) { if (!canEdit) {
return const SizedBox.shrink(); return const SizedBox.shrink();
} }
@@ -301,8 +310,9 @@ class _DocumentDetailsPageState extends State<DocumentDetailsPage> {
preferBelow: false, preferBelow: false,
verticalOffset: 40, verticalOffset: 40,
child: FloatingActionButton( child: FloatingActionButton(
heroTag: "fab_document_details",
child: const Icon(Icons.edit), child: const Icon(Icons.edit),
onPressed: () => _onEdit(state.document), onPressed: () => EditDocumentRoute(state.document).push(context),
), ),
); );
}, },
@@ -315,38 +325,48 @@ class _DocumentDetailsPageState extends State<DocumentDetailsPage> {
return BottomAppBar( return BottomAppBar(
child: BlocBuilder<ConnectivityCubit, ConnectivityState>( child: BlocBuilder<ConnectivityCubit, ConnectivityState>(
builder: (context, connectivityState) { builder: (context, connectivityState) {
final isConnected = connectivityState.isConnected; final currentUser = context.watch<LocalUserAccount>();
final canDelete = isConnected &&
LocalUserAccount.current.paperlessUser.canDeleteDocuments;
return Row( return Row(
mainAxisAlignment: MainAxisAlignment.start, mainAxisAlignment: MainAxisAlignment.start,
children: [ children: [
IconButton( ConnectivityAwareActionWrapper(
tooltip: S.of(context)!.deleteDocumentTooltip, disabled: !currentUser.paperlessUser.canDeleteDocuments,
icon: const Icon(Icons.delete), offlineBuilder: (context, child) {
onPressed: return const IconButton(
canDelete ? () => _onDelete(state.document) : null, icon: Icon(Icons.delete),
).paddedSymmetrically(horizontal: 4), onPressed: null,
DocumentDownloadButton( ).paddedSymmetrically(horizontal: 4);
document: state.document, },
enabled: isConnected, child: IconButton(
tooltip: S.of(context)!.deleteDocumentTooltip,
icon: const Icon(Icons.delete),
onPressed: () => _onDelete(state.document),
).paddedSymmetrically(horizontal: 4),
),
ConnectivityAwareActionWrapper(
offlineBuilder: (context, child) =>
const DocumentDownloadButton(
document: null,
enabled: false,
),
child: DocumentDownloadButton(
document: state.document,
),
),
ConnectivityAwareActionWrapper(
offlineBuilder: (context, child) => const IconButton(
icon: Icon(Icons.open_in_new),
onPressed: null,
),
child: IconButton(
tooltip: S.of(context)!.openInSystemViewer,
icon: const Icon(Icons.open_in_new),
onPressed: _onOpenFileInSystemViewer,
).paddedOnly(right: 4.0),
), ),
//TODO: Enable again, need new pdf viewer package...
IconButton(
tooltip: S.of(context)!.previewTooltip,
icon: const Icon(Icons.visibility),
onPressed:
(isConnected) ? () => _onOpen(state.document) : null,
).paddedOnly(right: 4.0),
IconButton(
tooltip: S.of(context)!.openInSystemViewer,
icon: const Icon(Icons.open_in_new),
onPressed: isConnected ? _onOpenFileInSystemViewer : null,
).paddedOnly(right: 4.0),
DocumentShareButton(document: state.document), DocumentShareButton(document: state.document),
IconButton( IconButton(
tooltip: S.of(context)!.print, //TODO: INTL tooltip: S.of(context)!.print,
onPressed: () => onPressed: () =>
context.read<DocumentDetailsCubit>().printDocument(), context.read<DocumentDetailsCubit>().printDocument(),
icon: const Icon(Icons.print), icon: const Icon(Icons.print),
@@ -360,47 +380,6 @@ class _DocumentDetailsPageState extends State<DocumentDetailsPage> {
); );
} }
Future<void> _onEdit(DocumentModel document) async {
{
final cubit = context.read<DocumentDetailsCubit>();
Navigator.push<bool>(
context,
MaterialPageRoute(
builder: (_) => MultiBlocProvider(
providers: [
BlocProvider.value(
value: DocumentEditCubit(
context.read(),
context.read(),
context.read(),
document: document,
),
),
BlocProvider<DocumentDetailsCubit>.value(
value: cubit,
),
],
child: BlocListener<DocumentEditCubit, DocumentEditState>(
listenWhen: (previous, current) =>
previous.document != current.document,
listener: (context, state) {
cubit.replace(state.document);
},
child: BlocBuilder<DocumentDetailsCubit, DocumentDetailsState>(
builder: (context, state) {
return DocumentEditPage(
suggestions: state.suggestions,
);
},
),
),
),
maintainState: true,
),
);
}
}
void _onOpenFileInSystemViewer() async { void _onOpenFileInSystemViewer() async {
final status = final status =
await context.read<DocumentDetailsCubit>().openDocumentInSystemViewer(); await context.read<DocumentDetailsCubit>().openDocumentInSystemViewer();
@@ -427,25 +406,21 @@ class _DocumentDetailsPageState extends State<DocumentDetailsPage> {
if (delete) { if (delete) {
try { try {
await context.read<DocumentDetailsCubit>().delete(document); await context.read<DocumentDetailsCubit>().delete(document);
showSnackBar(context, S.of(context)!.documentSuccessfullyDeleted); // showSnackBar(context, S.of(context)!.documentSuccessfullyDeleted);
} on PaperlessApiException catch (error, stackTrace) { } on PaperlessApiException catch (error, stackTrace) {
showErrorMessage(context, error, stackTrace); showErrorMessage(context, error, stackTrace);
} finally { } finally {
// Document deleted => go back to primary route do {
Navigator.popUntil(context, (route) => route.isFirst); context.pop();
} while (context.canPop());
} }
} }
} }
Future<void> _onOpen(DocumentModel document) async { Future<void> _onOpen(DocumentModel document) async {
Navigator.of(context).push( DocumentPreviewRoute(
MaterialPageRoute( $extra: document,
builder: (_) => DocumentView( title: document.title,
documentBytes: ).push(context);
context.read<PaperlessDocumentsApi>().download(document),
title: document.title,
),
),
);
} }
} }
@@ -47,7 +47,7 @@ class _ArchiveSerialNumberFieldState extends State<ArchiveSerialNumberField> {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final userCanEditDocument = final userCanEditDocument =
LocalUserAccount.current.paperlessUser.canEditDocuments; context.watch<LocalUserAccount>().paperlessUser.canEditDocuments;
return BlocListener<DocumentDetailsCubit, DocumentDetailsState>( return BlocListener<DocumentDetailsCubit, DocumentDetailsState>(
listenWhen: (previous, current) => listenWhen: (previous, current) =>
previous.document.archiveSerialNumber != previous.document.archiveSerialNumber !=
@@ -4,6 +4,7 @@ import 'package:flutter/material.dart';
import 'package:hive/hive.dart'; import 'package:hive/hive.dart';
import 'package:paperless_api/paperless_api.dart'; import 'package:paperless_api/paperless_api.dart';
import 'package:paperless_mobile/core/config/hive/hive_config.dart'; import 'package:paperless_mobile/core/config/hive/hive_config.dart';
import 'package:paperless_mobile/core/database/tables/local_user_account.dart';
import 'package:paperless_mobile/extensions/flutter_extensions.dart'; import 'package:paperless_mobile/extensions/flutter_extensions.dart';
import 'package:paperless_mobile/features/document_details/cubit/document_details_cubit.dart'; import 'package:paperless_mobile/features/document_details/cubit/document_details_cubit.dart';
import 'package:paperless_mobile/features/document_details/view/dialogs/select_file_type_dialog.dart'; import 'package:paperless_mobile/features/document_details/view/dialogs/select_file_type_dialog.dart';
@@ -90,9 +91,11 @@ class _DocumentDownloadButtonState extends State<DocumentDownloadButton> {
} }
setState(() => _isDownloadPending = true); setState(() => _isDownloadPending = true);
final userId = context.read<LocalUserAccount>().id;
await context.read<DocumentDetailsCubit>().downloadDocument( await context.read<DocumentDetailsCubit>().downloadDocument(
downloadOriginal: original, downloadOriginal: original,
locale: globalSettings.preferredLocaleSubtag, locale: globalSettings.preferredLocaleSubtag,
userId: userId,
); );
// showSnackBar(context, S.of(context)!.documentSuccessfullyDownloaded); // showSnackBar(context, S.of(context)!.documentSuccessfullyDownloaded);
} on PaperlessApiException catch (error, stackTrace) { } on PaperlessApiException catch (error, stackTrace) {
@@ -2,6 +2,7 @@ import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:intl/intl.dart'; import 'package:intl/intl.dart';
import 'package:paperless_api/paperless_api.dart'; import 'package:paperless_api/paperless_api.dart';
import 'package:paperless_mobile/core/database/tables/local_user_account.dart';
import 'package:paperless_mobile/extensions/flutter_extensions.dart'; import 'package:paperless_mobile/extensions/flutter_extensions.dart';
import 'package:paperless_mobile/features/document_details/cubit/document_details_cubit.dart'; import 'package:paperless_mobile/features/document_details/cubit/document_details_cubit.dart';
import 'package:paperless_mobile/features/document_details/view/widgets/archive_serial_number_field.dart'; import 'package:paperless_mobile/features/document_details/view/widgets/archive_serial_number_field.dart';
@@ -25,6 +26,7 @@ class DocumentMetaDataWidget extends StatefulWidget {
class _DocumentMetaDataWidgetState extends State<DocumentMetaDataWidget> { class _DocumentMetaDataWidgetState extends State<DocumentMetaDataWidget> {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final currentUser = context.watch<LocalUserAccount>().paperlessUser;
return BlocBuilder<DocumentDetailsCubit, DocumentDetailsState>( return BlocBuilder<DocumentDetailsCubit, DocumentDetailsState>(
builder: (context, state) { builder: (context, state) {
if (state.metaData == null) { if (state.metaData == null) {
@@ -37,9 +39,10 @@ class _DocumentMetaDataWidgetState extends State<DocumentMetaDataWidget> {
return SliverList( return SliverList(
delegate: SliverChildListDelegate( delegate: SliverChildListDelegate(
[ [
ArchiveSerialNumberField( if (currentUser.canEditDocuments)
document: widget.document, ArchiveSerialNumberField(
).paddedOnly(bottom: widget.itemSpacing), document: widget.document,
).paddedOnly(bottom: widget.itemSpacing),
DetailsItem.text( DetailsItem.text(
DateFormat().format(widget.document.modified), DateFormat().format(widget.document.modified),
context: context, context: context,
@@ -1,4 +1,5 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:intl/intl.dart'; import 'package:intl/intl.dart';
import 'package:paperless_api/paperless_api.dart'; import 'package:paperless_api/paperless_api.dart';
import 'package:paperless_mobile/core/database/tables/local_user_account.dart'; import 'package:paperless_mobile/core/database/tables/local_user_account.dart';
@@ -30,62 +31,66 @@ class DocumentOverviewWidget extends StatelessWidget {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return SliverList( return SliverList.list(
delegate: SliverChildListDelegate( children: [
[ DetailsItem(
label: S.of(context)!.title,
content: HighlightedText(
text: document.title,
highlights: queryString?.split(" ") ?? [],
style: Theme.of(context).textTheme.bodyLarge,
),
).paddedOnly(bottom: itemSpacing),
DetailsItem.text(
DateFormat.yMMMMd().format(document.created),
context: context,
label: S.of(context)!.createdAt,
).paddedOnly(bottom: itemSpacing),
if (document.documentType != null &&
context
.watch<LocalUserAccount>()
.paperlessUser
.canViewDocumentTypes)
DetailsItem( DetailsItem(
label: S.of(context)!.title, label: S.of(context)!.documentType,
content: HighlightedText( content: LabelText<DocumentType>(
text: document.title,
highlights: queryString?.split(" ") ?? [],
style: Theme.of(context).textTheme.bodyLarge, style: Theme.of(context).textTheme.bodyLarge,
label: availableDocumentTypes[document.documentType],
), ),
).paddedOnly(bottom: itemSpacing), ).paddedOnly(bottom: itemSpacing),
DetailsItem.text( if (document.correspondent != null &&
DateFormat.yMMMMd().format(document.created), context
context: context, .watch<LocalUserAccount>()
label: S.of(context)!.createdAt, .paperlessUser
.canViewCorrespondents)
DetailsItem(
label: S.of(context)!.correspondent,
content: LabelText<Correspondent>(
style: Theme.of(context).textTheme.bodyLarge,
label: availableCorrespondents[document.correspondent],
),
).paddedOnly(bottom: itemSpacing), ).paddedOnly(bottom: itemSpacing),
if (document.documentType != null && if (document.storagePath != null &&
LocalUserAccount.current.paperlessUser.canViewDocumentTypes) context.watch<LocalUserAccount>().paperlessUser.canViewStoragePaths)
DetailsItem( DetailsItem(
label: S.of(context)!.documentType, label: S.of(context)!.storagePath,
content: LabelText<DocumentType>( content: LabelText<StoragePath>(
style: Theme.of(context).textTheme.bodyLarge, label: availableStoragePaths[document.storagePath],
label: availableDocumentTypes[document.documentType], ),
).paddedOnly(bottom: itemSpacing),
if (document.tags.isNotEmpty &&
context.watch<LocalUserAccount>().paperlessUser.canViewTags)
DetailsItem(
label: S.of(context)!.tags,
content: Padding(
padding: const EdgeInsets.only(top: 8.0),
child: TagsWidget(
isClickable: false,
tags: document.tags.map((e) => availableTags[e]!).toList(),
), ),
).paddedOnly(bottom: itemSpacing), ),
if (document.correspondent != null && ).paddedOnly(bottom: itemSpacing),
LocalUserAccount.current.paperlessUser.canViewCorrespondents) ],
DetailsItem(
label: S.of(context)!.correspondent,
content: LabelText<Correspondent>(
style: Theme.of(context).textTheme.bodyLarge,
label: availableCorrespondents[document.correspondent],
),
).paddedOnly(bottom: itemSpacing),
if (document.storagePath != null &&
LocalUserAccount.current.paperlessUser.canViewStoragePaths)
DetailsItem(
label: S.of(context)!.storagePath,
content: LabelText<StoragePath>(
label: availableStoragePaths[document.storagePath],
),
).paddedOnly(bottom: itemSpacing),
if (document.tags.isNotEmpty &&
LocalUserAccount.current.paperlessUser.canViewTags)
DetailsItem(
label: S.of(context)!.tags,
content: Padding(
padding: const EdgeInsets.only(top: 8.0),
child: TagsWidget(
isClickable: false,
tags: document.tags.map((e) => availableTags[e]!).toList(),
),
),
).paddedOnly(bottom: itemSpacing),
],
),
); );
} }
} }
@@ -11,6 +11,7 @@ import 'package:paperless_mobile/features/document_details/cubit/document_detail
import 'package:paperless_mobile/features/document_details/view/dialogs/select_file_type_dialog.dart'; import 'package:paperless_mobile/features/document_details/view/dialogs/select_file_type_dialog.dart';
import 'package:paperless_mobile/features/settings/model/file_download_type.dart'; import 'package:paperless_mobile/features/settings/model/file_download_type.dart';
import 'package:paperless_mobile/generated/l10n/app_localizations.dart'; import 'package:paperless_mobile/generated/l10n/app_localizations.dart';
import 'package:paperless_mobile/helpers/connectivity_aware_action_wrapper.dart';
import 'package:paperless_mobile/helpers/message_helpers.dart'; import 'package:paperless_mobile/helpers/message_helpers.dart';
import 'package:paperless_mobile/helpers/permission_helpers.dart'; import 'package:paperless_mobile/helpers/permission_helpers.dart';
import 'package:permission_handler/permission_handler.dart'; import 'package:permission_handler/permission_handler.dart';
@@ -34,19 +35,25 @@ class _DocumentShareButtonState extends State<DocumentShareButton> {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return IconButton( return ConnectivityAwareActionWrapper(
tooltip: S.of(context)!.shareTooltip, offlineBuilder: (context, child) => const IconButton(
icon: _isDownloadPending icon: Icon(Icons.share),
? const SizedBox( onPressed: null,
height: 16, ),
width: 16, child: IconButton(
child: CircularProgressIndicator(), tooltip: S.of(context)!.shareTooltip,
) icon: _isDownloadPending
: const Icon(Icons.share), ? const SizedBox(
onPressed: widget.document != null && widget.enabled height: 16,
? () => _onShare(widget.document!) width: 16,
: null, child: CircularProgressIndicator(),
).paddedOnly(right: 4); )
: const Icon(Icons.share),
onPressed: widget.document != null && widget.enabled
? () => _onShare(widget.document!)
: null,
).paddedOnly(right: 4),
);
} }
Future<void> _onShare(DocumentModel document) async { Future<void> _onShare(DocumentModel document) async {
@@ -57,6 +57,11 @@ class DocumentEditCubit extends Cubit<DocumentEditState> {
} }
} }
Future<void> loadFieldSuggestions() async {
final suggestions = await _docsApi.findSuggestions(state.document);
emit(state.copyWith(suggestions: suggestions));
}
void replace(DocumentModel document) { void replace(DocumentModel document) {
emit(state.copyWith(document: document)); emit(state.copyWith(document: document));
} }
@@ -4,6 +4,7 @@ part of 'document_edit_cubit.dart';
class DocumentEditState with _$DocumentEditState { class DocumentEditState with _$DocumentEditState {
const factory DocumentEditState({ const factory DocumentEditState({
required DocumentModel document, required DocumentModel document,
FieldSuggestions? suggestions,
@Default({}) Map<int, Correspondent> correspondents, @Default({}) Map<int, Correspondent> correspondents,
@Default({}) Map<int, DocumentType> documentTypes, @Default({}) Map<int, DocumentType> documentTypes,
@Default({}) Map<int, StoragePath> storagePaths, @Default({}) Map<int, StoragePath> storagePaths,
@@ -4,11 +4,12 @@ import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:flutter_form_builder/flutter_form_builder.dart'; import 'package:flutter_form_builder/flutter_form_builder.dart';
import 'package:flutter_typeahead/flutter_typeahead.dart'; import 'package:flutter_typeahead/flutter_typeahead.dart';
import 'package:go_router/go_router.dart';
import 'package:intl/intl.dart'; import 'package:intl/intl.dart';
import 'package:paperless_api/paperless_api.dart'; import 'package:paperless_api/paperless_api.dart';
import 'package:paperless_mobile/core/database/tables/local_user_account.dart'; import 'package:paperless_mobile/core/database/tables/local_user_account.dart';
import 'package:paperless_mobile/core/repository/label_repository.dart'; import 'package:paperless_mobile/core/repository/label_repository.dart';
import 'package:paperless_mobile/core/widgets/dialog_utils/pop_with_unsaved_changes.dart';
import 'package:paperless_mobile/core/workarounds/colored_chip.dart'; import 'package:paperless_mobile/core/workarounds/colored_chip.dart';
import 'package:paperless_mobile/extensions/flutter_extensions.dart'; import 'package:paperless_mobile/extensions/flutter_extensions.dart';
import 'package:paperless_mobile/features/document_edit/cubit/document_edit_cubit.dart'; import 'package:paperless_mobile/features/document_edit/cubit/document_edit_cubit.dart';
@@ -18,14 +19,11 @@ import 'package:paperless_mobile/features/edit_label/view/impl/add_storage_path_
import 'package:paperless_mobile/features/labels/tags/view/widgets/tags_form_field.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/labels/view/widgets/label_form_field.dart';
import 'package:paperless_mobile/generated/l10n/app_localizations.dart'; import 'package:paperless_mobile/generated/l10n/app_localizations.dart';
import 'package:paperless_mobile/helpers/message_helpers.dart'; import 'package:paperless_mobile/helpers/message_helpers.dart';
class DocumentEditPage extends StatefulWidget { class DocumentEditPage extends StatefulWidget {
final FieldSuggestions? suggestions;
const DocumentEditPage({ const DocumentEditPage({
Key? key, Key? key,
required this.suggestions,
}) : super(key: key); }) : super(key: key);
@override @override
@@ -42,256 +40,261 @@ class _DocumentEditPageState extends State<DocumentEditPage> {
static const fkContent = 'content'; static const fkContent = 'content';
final GlobalKey<FormBuilderState> _formKey = GlobalKey(); final GlobalKey<FormBuilderState> _formKey = GlobalKey();
bool _isSubmitLoading = false;
late final FieldSuggestions? _filteredSuggestions;
@override
void initState() {
super.initState();
_filteredSuggestions = widget.suggestions
?.documentDifference(context.read<DocumentEditCubit>().state.document);
}
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return BlocBuilder<DocumentEditCubit, DocumentEditState>( final currentUser = context.watch<LocalUserAccount>().paperlessUser;
builder: (context, state) { return PopWithUnsavedChanges(
return DefaultTabController( hasChangesPredicate: () => _formKey.currentState?.isDirty ?? false,
length: 2, child: BlocBuilder<DocumentEditCubit, DocumentEditState>(
child: Scaffold( builder: (context, state) {
resizeToAvoidBottomInset: false, final filteredSuggestions = state.suggestions?.documentDifference(
floatingActionButton: FloatingActionButton.extended( context.read<DocumentEditCubit>().state.document);
onPressed: () => _onSubmit(state.document), return DefaultTabController(
icon: const Icon(Icons.save), length: 2,
label: Text(S.of(context)!.saveChanges), child: Scaffold(
), resizeToAvoidBottomInset: false,
appBar: AppBar( floatingActionButton: FloatingActionButton.extended(
title: Text(S.of(context)!.editDocument), heroTag: "fab_document_edit",
bottom: TabBar( onPressed: () => _onSubmit(state.document),
tabs: [ icon: const Icon(Icons.save),
Tab( label: Text(S.of(context)!.saveChanges),
text: S.of(context)!.overview,
),
Tab(
text: S.of(context)!.content,
)
],
), ),
), appBar: AppBar(
extendBody: true, title: Text(S.of(context)!.editDocument),
body: Padding( bottom: TabBar(
padding: EdgeInsets.only( tabs: [
bottom: MediaQuery.of(context).viewInsets.bottom, Tab(text: S.of(context)!.overview),
top: 8, Tab(text: S.of(context)!.content)
left: 8,
right: 8,
),
child: FormBuilder(
key: _formKey,
child: TabBarView(
children: [
ListView(
children: [
_buildTitleFormField(state.document.title).padded(),
_buildCreatedAtFormField(state.document.created)
.padded(),
// Correspondent form field
Column(
children: [
LabelFormField<Correspondent>(
showAnyAssignedOption: false,
showNotAssignedOption: false,
addLabelPageBuilder: (initialValue) =>
RepositoryProvider.value(
value: context.read<LabelRepository>(),
child: AddCorrespondentPage(
initialName: initialValue,
),
),
addLabelText: S.of(context)!.addCorrespondent,
labelText: S.of(context)!.correspondent,
options: context
.watch<DocumentEditCubit>()
.state
.correspondents,
initialValue:
state.document.correspondent != null
? IdQueryParameter.fromId(
state.document.correspondent!)
: const IdQueryParameter.unset(),
name: fkCorrespondent,
prefixIcon: const Icon(Icons.person_outlined),
allowSelectUnassigned: true,
canCreateNewLabel: LocalUserAccount.current
.paperlessUser.canCreateCorrespondents,
),
if (_filteredSuggestions
?.hasSuggestedCorrespondents ??
false)
_buildSuggestionsSkeleton<int>(
suggestions:
_filteredSuggestions!.correspondents,
itemBuilder: (context, itemData) =>
ActionChip(
label: Text(
state.correspondents[itemData]!.name),
onPressed: () {
_formKey
.currentState?.fields[fkCorrespondent]
?.didChange(
IdQueryParameter.fromId(itemData),
);
},
),
),
],
).padded(),
// DocumentType form field
Column(
children: [
LabelFormField<DocumentType>(
showAnyAssignedOption: false,
showNotAssignedOption: false,
addLabelPageBuilder: (currentInput) =>
RepositoryProvider.value(
value: context.read<LabelRepository>(),
child: AddDocumentTypePage(
initialName: currentInput,
),
),
canCreateNewLabel: LocalUserAccount.current
.paperlessUser.canCreateDocumentTypes,
addLabelText: S.of(context)!.addDocumentType,
labelText: S.of(context)!.documentType,
initialValue:
state.document.documentType != null
? IdQueryParameter.fromId(
state.document.documentType!)
: const IdQueryParameter.unset(),
options: state.documentTypes,
name: _DocumentEditPageState.fkDocumentType,
prefixIcon:
const Icon(Icons.description_outlined),
allowSelectUnassigned: true,
),
if (_filteredSuggestions
?.hasSuggestedDocumentTypes ??
false)
_buildSuggestionsSkeleton<int>(
suggestions:
_filteredSuggestions!.documentTypes,
itemBuilder: (context, itemData) =>
ActionChip(
label: Text(
state.documentTypes[itemData]!.name),
onPressed: () => _formKey
.currentState?.fields[fkDocumentType]
?.didChange(
IdQueryParameter.fromId(itemData),
),
),
),
],
).padded(),
// StoragePath form field
Column(
children: [
LabelFormField<StoragePath>(
showAnyAssignedOption: false,
showNotAssignedOption: false,
addLabelPageBuilder: (initialValue) =>
RepositoryProvider.value(
value: context.read<LabelRepository>(),
child: AddStoragePathPage(
initalName: initialValue),
),
canCreateNewLabel: LocalUserAccount.current
.paperlessUser.canCreateStoragePaths,
addLabelText: S.of(context)!.addStoragePath,
labelText: S.of(context)!.storagePath,
options: state.storagePaths,
initialValue: state.document.storagePath != null
? IdQueryParameter.fromId(
state.document.storagePath!)
: const IdQueryParameter.unset(),
name: fkStoragePath,
prefixIcon: const Icon(Icons.folder_outlined),
allowSelectUnassigned: true,
),
],
).padded(),
// Tag form field
TagsFormField(
options: state.tags,
name: fkTags,
allowOnlySelection: true,
allowCreation: true,
allowExclude: false,
initialValue: TagsQuery.ids(
include: state.document.tags.toList(),
),
).padded(),
if (_filteredSuggestions?.tags
.toSet()
.difference(state.document.tags.toSet())
.isNotEmpty ??
false)
_buildSuggestionsSkeleton<int>(
suggestions:
(_filteredSuggestions?.tags.toSet() ?? {}),
itemBuilder: (context, itemData) {
final tag = state.tags[itemData]!;
return ActionChip(
label: Text(
tag.name,
style: TextStyle(color: tag.textColor),
),
backgroundColor: tag.color,
onPressed: () {
final currentTags = _formKey.currentState
?.fields[fkTags]?.value as TagsQuery;
_formKey.currentState?.fields[fkTags]
?.didChange(
currentTags.maybeWhen(
ids: (include, exclude) =>
TagsQuery.ids(
include: [...include, itemData],
exclude: exclude),
orElse: () =>
TagsQuery.ids(include: [itemData]),
),
);
},
);
},
),
// Prevent tags from being hidden by fab
const SizedBox(height: 64),
],
),
SingleChildScrollView(
child: Column(
children: [
FormBuilderTextField(
name: fkContent,
maxLines: null,
keyboardType: TextInputType.multiline,
initialValue: state.document.content,
decoration: const InputDecoration(
border: InputBorder.none,
),
),
const SizedBox(height: 84),
],
),
),
], ],
), ),
), ),
)), extendBody: true,
); body: Padding(
}, padding: EdgeInsets.only(
bottom: MediaQuery.of(context).viewInsets.bottom,
top: 8,
left: 8,
right: 8,
),
child: FormBuilder(
key: _formKey,
child: TabBarView(
children: [
ListView(
children: [
_buildTitleFormField(state.document.title).padded(),
_buildCreatedAtFormField(
state.document.created,
filteredSuggestions,
).padded(),
// Correspondent form field
if (currentUser.canViewCorrespondents)
Column(
children: [
LabelFormField<Correspondent>(
showAnyAssignedOption: false,
showNotAssignedOption: false,
addLabelPageBuilder: (initialValue) =>
RepositoryProvider.value(
value: context.read<LabelRepository>(),
child: AddCorrespondentPage(
initialName: initialValue,
),
),
addLabelText:
S.of(context)!.addCorrespondent,
labelText: S.of(context)!.correspondent,
options: context
.watch<DocumentEditCubit>()
.state
.correspondents,
initialValue:
state.document.correspondent != null
? IdQueryParameter.fromId(
state.document.correspondent!)
: const IdQueryParameter.unset(),
name: fkCorrespondent,
prefixIcon:
const Icon(Icons.person_outlined),
allowSelectUnassigned: true,
canCreateNewLabel:
currentUser.canCreateCorrespondents,
),
if (filteredSuggestions
?.hasSuggestedCorrespondents ??
false)
_buildSuggestionsSkeleton<int>(
suggestions:
filteredSuggestions!.correspondents,
itemBuilder: (context, itemData) =>
ActionChip(
label: Text(state
.correspondents[itemData]!.name),
onPressed: () {
_formKey.currentState
?.fields[fkCorrespondent]
?.didChange(
IdQueryParameter.fromId(itemData),
);
},
),
),
],
).padded(),
// DocumentType form field
if (currentUser.canViewDocumentTypes)
Column(
children: [
LabelFormField<DocumentType>(
showAnyAssignedOption: false,
showNotAssignedOption: false,
addLabelPageBuilder: (currentInput) =>
RepositoryProvider.value(
value: context.read<LabelRepository>(),
child: AddDocumentTypePage(
initialName: currentInput,
),
),
canCreateNewLabel:
currentUser.canCreateDocumentTypes,
addLabelText:
S.of(context)!.addDocumentType,
labelText: S.of(context)!.documentType,
initialValue:
state.document.documentType != null
? IdQueryParameter.fromId(
state.document.documentType!)
: const IdQueryParameter.unset(),
options: state.documentTypes,
name: _DocumentEditPageState.fkDocumentType,
prefixIcon:
const Icon(Icons.description_outlined),
allowSelectUnassigned: true,
),
if (filteredSuggestions
?.hasSuggestedDocumentTypes ??
false)
_buildSuggestionsSkeleton<int>(
suggestions:
filteredSuggestions!.documentTypes,
itemBuilder: (context, itemData) =>
ActionChip(
label: Text(state
.documentTypes[itemData]!.name),
onPressed: () => _formKey.currentState
?.fields[fkDocumentType]
?.didChange(
IdQueryParameter.fromId(itemData),
),
),
),
],
).padded(),
// StoragePath form field
if (currentUser.canViewStoragePaths)
Column(
children: [
LabelFormField<StoragePath>(
showAnyAssignedOption: false,
showNotAssignedOption: false,
addLabelPageBuilder: (initialValue) =>
RepositoryProvider.value(
value: context.read<LabelRepository>(),
child: AddStoragePathPage(
initialName: initialValue),
),
canCreateNewLabel:
currentUser.canCreateStoragePaths,
addLabelText: S.of(context)!.addStoragePath,
labelText: S.of(context)!.storagePath,
options: state.storagePaths,
initialValue:
state.document.storagePath != null
? IdQueryParameter.fromId(
state.document.storagePath!)
: const IdQueryParameter.unset(),
name: fkStoragePath,
prefixIcon:
const Icon(Icons.folder_outlined),
allowSelectUnassigned: true,
),
],
).padded(),
// Tag form field
if (currentUser.canViewTags)
TagsFormField(
options: state.tags,
name: fkTags,
allowOnlySelection: true,
allowCreation: true,
allowExclude: false,
initialValue: TagsQuery.ids(
include: state.document.tags.toList(),
),
).padded(),
if (filteredSuggestions?.tags
.toSet()
.difference(state.document.tags.toSet())
.isNotEmpty ??
false)
_buildSuggestionsSkeleton<int>(
suggestions:
(filteredSuggestions?.tags.toSet() ?? {}),
itemBuilder: (context, itemData) {
final tag = state.tags[itemData]!;
return ActionChip(
label: Text(
tag.name,
style: TextStyle(color: tag.textColor),
),
backgroundColor: tag.color,
onPressed: () {
final currentTags = _formKey.currentState
?.fields[fkTags]?.value as TagsQuery;
_formKey.currentState?.fields[fkTags]
?.didChange(
currentTags.maybeWhen(
ids: (include, exclude) =>
TagsQuery.ids(include: [
...include,
itemData
], exclude: exclude),
orElse: () => TagsQuery.ids(
include: [itemData]),
),
);
},
);
},
),
// Prevent tags from being hidden by fab
const SizedBox(height: 64),
],
),
SingleChildScrollView(
child: Column(
children: [
FormBuilderTextField(
name: fkContent,
maxLines: null,
keyboardType: TextInputType.multiline,
initialValue: state.document.content,
decoration: const InputDecoration(
border: InputBorder.none,
),
),
const SizedBox(height: 84),
],
),
),
],
),
),
)),
);
},
),
); );
} }
@@ -301,28 +304,23 @@ class _DocumentEditPageState extends State<DocumentEditPage> {
var mergedDocument = document.copyWith( var mergedDocument = document.copyWith(
title: values[fkTitle], title: values[fkTitle],
created: values[fkCreatedDate], created: values[fkCreatedDate],
documentType: () => (values[fkDocumentType] as IdQueryParameter) documentType: () => (values[fkDocumentType] as IdQueryParameter?)
.whenOrNull(fromId: (id) => id), ?.whenOrNull(fromId: (id) => id),
correspondent: () => (values[fkCorrespondent] as IdQueryParameter) correspondent: () => (values[fkCorrespondent] as IdQueryParameter?)
.whenOrNull(fromId: (id) => id), ?.whenOrNull(fromId: (id) => id),
storagePath: () => (values[fkStoragePath] as IdQueryParameter) storagePath: () => (values[fkStoragePath] as IdQueryParameter?)
.whenOrNull(fromId: (id) => id), ?.whenOrNull(fromId: (id) => id),
tags: (values[fkTags] as IdsTagsQuery).include, tags: (values[fkTags] as IdsTagsQuery?)?.include,
content: values[fkContent], content: values[fkContent],
); );
setState(() {
_isSubmitLoading = true;
});
try { try {
await context.read<DocumentEditCubit>().updateDocument(mergedDocument); await context.read<DocumentEditCubit>().updateDocument(mergedDocument);
showSnackBar(context, S.of(context)!.documentSuccessfullyUpdated); showSnackBar(context, S.of(context)!.documentSuccessfullyUpdated);
} on PaperlessApiException catch (error, stackTrace) { } on PaperlessApiException catch (error, stackTrace) {
showErrorMessage(context, error, stackTrace); showErrorMessage(context, error, stackTrace);
} finally { } finally {
setState(() { context.pop();
_isSubmitLoading = false;
});
Navigator.pop(context);
} }
} }
} }
@@ -343,7 +341,8 @@ class _DocumentEditPageState extends State<DocumentEditPage> {
); );
} }
Widget _buildCreatedAtFormField(DateTime? initialCreatedAtDate) { Widget _buildCreatedAtFormField(
DateTime? initialCreatedAtDate, FieldSuggestions? filteredSuggestions) {
return Column( return Column(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
@@ -358,9 +357,9 @@ class _DocumentEditPageState extends State<DocumentEditPage> {
format: DateFormat.yMMMMd(), format: DateFormat.yMMMMd(),
initialEntryMode: DatePickerEntryMode.calendar, initialEntryMode: DatePickerEntryMode.calendar,
), ),
if (_filteredSuggestions?.hasSuggestedDates ?? false) if (filteredSuggestions?.hasSuggestedDates ?? false)
_buildSuggestionsSkeleton<DateTime>( _buildSuggestionsSkeleton<DateTime>(
suggestions: _filteredSuggestions!.dates, suggestions: filteredSuggestions!.dates,
itemBuilder: (context, itemData) => ActionChip( itemBuilder: (context, itemData) => ActionChip(
label: Text(DateFormat.yMMMd().format(itemData)), label: Text(DateFormat.yMMMd().format(itemData)),
onPressed: () => _formKey.currentState?.fields[fkCreatedDate] onPressed: () => _formKey.currentState?.fields[fkCreatedDate]
@@ -1,43 +1,71 @@
import 'dart:developer';
import 'dart:io'; import 'dart:io';
import 'package:flutter/foundation.dart'; import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:paperless_api/paperless_api.dart'; import 'package:paperless_api/paperless_api.dart';
import 'package:paperless_mobile/core/model/info_message_exception.dart';
import 'package:paperless_mobile/core/service/file_service.dart'; import 'package:paperless_mobile/core/service/file_service.dart';
import 'package:paperless_mobile/features/notifications/services/local_notification_service.dart'; import 'package:paperless_mobile/features/notifications/services/local_notification_service.dart';
import 'package:rxdart/rxdart.dart';
class DocumentScannerCubit extends Cubit<List<File>> { part 'document_scanner_state.dart';
class DocumentScannerCubit extends Cubit<DocumentScannerState> {
final LocalNotificationService _notificationService; final LocalNotificationService _notificationService;
DocumentScannerCubit(this._notificationService) : super(const []); DocumentScannerCubit(this._notificationService)
: super(const InitialDocumentScannerState());
void addScan(File file) => emit([...state, file]); Future<void> initialize() async {
debugPrint("Restoring scans...");
void removeScan(int fileIndex) { emit(const RestoringDocumentScannerState());
try { final tempDir = await FileService.temporaryScansDirectory;
state[fileIndex].deleteSync(); final allFiles = tempDir.list().whereType<File>();
final scans = [...state]; final scans =
scans.removeAt(fileIndex); await allFiles.where((event) => event.path.endsWith(".jpeg")).toList();
emit(scans); debugPrint("Restored ${scans.length} scans.");
} catch (_) { emit(
throw const PaperlessApiException(ErrorCode.scanRemoveFailed); scans.isEmpty
} ? const InitialDocumentScannerState()
: LoadedDocumentScannerState(scans: scans),
);
} }
void reset() { void addScan(File file) async {
emit(LoadedDocumentScannerState(
scans: [...state.scans, file],
));
}
Future<void> removeScan(File file) async {
try { try {
for (final doc in state) { await file.delete();
doc.deleteSync(); } catch (error, stackTrace) {
if (kDebugMode) { throw InfoMessageException(
log('[ScannerCubit]: Removed ${doc.path}'); code: ErrorCode.scanRemoveFailed,
} message: error.toString(),
} stackTrace: stackTrace,
);
}
final scans = state.scans..remove(file);
emit(
scans.isEmpty
? const InitialDocumentScannerState()
: LoadedDocumentScannerState(scans: scans),
);
}
Future<void> reset() async {
try {
Future.wait([
for (final file in state.scans) file.delete(),
]);
imageCache.clear(); imageCache.clear();
emit([]);
} catch (_) { } catch (_) {
throw const PaperlessApiException(ErrorCode.scanRemoveFailed); throw const PaperlessApiException(ErrorCode.scanRemoveFailed);
} finally {
emit(const InitialDocumentScannerState());
} }
} }
@@ -0,0 +1,30 @@
part of 'document_scanner_cubit.dart';
sealed class DocumentScannerState {
final List<File> scans;
const DocumentScannerState({
this.scans = const [],
});
}
class InitialDocumentScannerState extends DocumentScannerState {
const InitialDocumentScannerState();
}
class RestoringDocumentScannerState extends DocumentScannerState {
const RestoringDocumentScannerState({super.scans});
}
class LoadedDocumentScannerState extends DocumentScannerState {
const LoadedDocumentScannerState({super.scans});
}
class ErrorDocumentScannerState extends DocumentScannerState {
final String message;
const ErrorDocumentScannerState({
required this.message,
super.scans,
});
}
@@ -1,44 +0,0 @@
import 'dart:io';
import 'dart:isolate';
import 'package:image/image.dart' as im;
typedef ImageOperationCallback = im.Image Function(im.Image);
class DecodeParam {
final File file;
final SendPort sendPort;
final im.Image Function(im.Image) imageOperation;
DecodeParam(this.file, this.sendPort, this.imageOperation);
}
void decodeIsolate(DecodeParam param) {
// Read an image from file (webp in this case).
// decodeImage will identify the format of the image and use the appropriate
// decoder.
var image = im.decodeImage(param.file.readAsBytesSync())!;
// Resize the image to a 120x? thumbnail (maintaining the aspect ratio).
var processed = param.imageOperation(image);
param.sendPort.send(processed);
}
// Decode and process an image file in a separate thread (isolate) to avoid
// stalling the main UI thread.
Future<File> processImage(
File file,
ImageOperationCallback imageOperation,
) async {
var receivePort = ReceivePort();
await Isolate.spawn(
decodeIsolate,
DecodeParam(
file,
receivePort.sendPort,
imageOperation,
));
var image = await receivePort.first as im.Image;
return file.writeAsBytes(im.encodePng(image));
}
+104 -101
View File
@@ -10,23 +10,22 @@ import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:hive/hive.dart'; import 'package:hive/hive.dart';
import 'package:paperless_api/paperless_api.dart'; import 'package:paperless_api/paperless_api.dart';
import 'package:paperless_mobile/constants.dart'; import 'package:paperless_mobile/constants.dart';
import 'package:paperless_mobile/core/bloc/connectivity_cubit.dart';
import 'package:paperless_mobile/core/config/hive/hive_config.dart'; import 'package:paperless_mobile/core/config/hive/hive_config.dart';
import 'package:paperless_mobile/core/database/tables/global_settings.dart'; import 'package:paperless_mobile/core/database/tables/global_settings.dart';
import 'package:paperless_mobile/core/global/constants.dart'; import 'package:paperless_mobile/core/global/constants.dart';
import 'package:paperless_mobile/core/navigation/push_routes.dart';
import 'package:paperless_mobile/core/service/file_description.dart';
import 'package:paperless_mobile/core/service/file_service.dart'; import 'package:paperless_mobile/core/service/file_service.dart';
import 'package:paperless_mobile/features/app_drawer/view/app_drawer.dart'; import 'package:paperless_mobile/features/app_drawer/view/app_drawer.dart';
import 'package:paperless_mobile/features/document_scan/cubit/document_scanner_cubit.dart'; import 'package:paperless_mobile/features/document_scan/cubit/document_scanner_cubit.dart';
import 'package:paperless_mobile/features/document_scan/view/widgets/export_scans_dialog.dart'; import 'package:paperless_mobile/features/document_scan/view/widgets/export_scans_dialog.dart';
import 'package:paperless_mobile/features/document_scan/view/widgets/scanned_image_item.dart'; import 'package:paperless_mobile/features/document_scan/view/widgets/scanned_image_item.dart';
import 'package:paperless_mobile/features/document_search/view/sliver_search_bar.dart'; import 'package:paperless_mobile/features/document_search/view/sliver_search_bar.dart';
import 'package:paperless_mobile/features/document_upload/view/document_upload_preparation_page.dart';
import 'package:paperless_mobile/features/documents/view/pages/document_view.dart'; import 'package:paperless_mobile/features/documents/view/pages/document_view.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/generated/l10n/app_localizations.dart';
import 'package:paperless_mobile/helpers/connectivity_aware_action_wrapper.dart';
import 'package:paperless_mobile/helpers/message_helpers.dart'; import 'package:paperless_mobile/helpers/message_helpers.dart';
import 'package:paperless_mobile/helpers/permission_helpers.dart'; import 'package:paperless_mobile/helpers/permission_helpers.dart';
import 'package:paperless_mobile/routes/typed/branches/scanner_route.dart';
import 'package:path/path.dart' as p; import 'package:path/path.dart' as p;
import 'package:pdf/pdf.dart'; import 'package:pdf/pdf.dart';
import 'package:pdf/widgets.dart' as pw; import 'package:pdf/widgets.dart' as pw;
@@ -51,71 +50,54 @@ class _ScannerPageState extends State<ScannerPage>
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return BlocBuilder<ConnectivityCubit, ConnectivityState>( return SafeArea(
builder: (context, connectedState) { top: true,
return Scaffold( child: Scaffold(
drawer: const AppDrawer(), drawer: const AppDrawer(),
floatingActionButton: FloatingActionButton( floatingActionButton: FloatingActionButton(
onPressed: () => _openDocumentScanner(context), heroTag: "fab_document_edit",
child: const Icon(Icons.add_a_photo_outlined), onPressed: () => _openDocumentScanner(context),
), child: const Icon(Icons.add_a_photo_outlined),
body: BlocBuilder<DocumentScannerCubit, List<File>>( ),
body: NestedScrollView(
floatHeaderSlivers: true,
headerSliverBuilder: (context, innerBoxIsScrolled) => [
SliverOverlapAbsorber(
handle: searchBarHandle,
sliver: SliverSearchBar(
titleText: S.of(context)!.scanner,
),
),
SliverOverlapAbsorber(
handle: actionsHandle,
sliver: SliverPinnedHeader(
child: _buildActions(),
),
),
],
body: BlocBuilder<DocumentScannerCubit, DocumentScannerState>(
builder: (context, state) { builder: (context, state) {
return SafeArea( return switch (state) {
child: Scaffold( InitialDocumentScannerState() => _buildEmptyState(),
drawer: const AppDrawer(), RestoringDocumentScannerState() => Center(
floatingActionButton: FloatingActionButton( child: Text("Restoring..."),
onPressed: () => _openDocumentScanner(context),
child: const Icon(Icons.add_a_photo_outlined),
), ),
body: NestedScrollView( LoadedDocumentScannerState() => _buildImageGrid(state.scans),
floatHeaderSlivers: true, ErrorDocumentScannerState() => Placeholder(),
headerSliverBuilder: (context, innerBoxIsScrolled) => [ };
SliverOverlapAbsorber(
handle: searchBarHandle,
sliver: SliverSearchBar(
titleText: S.of(context)!.scanner,
),
),
SliverOverlapAbsorber(
handle: actionsHandle,
sliver: SliverPinnedHeader(
child: _buildActions(connectedState.isConnected),
),
),
],
body: BlocBuilder<DocumentScannerCubit, List<File>>(
builder: (context, state) {
if (state.isEmpty) {
return SizedBox.expand(
child: Center(
child: _buildEmptyState(
connectedState.isConnected,
state,
),
),
);
} else {
return _buildImageGrid(state);
}
},
),
),
),
);
}, },
), ),
); ),
}, ),
); );
} }
Widget _buildActions(bool isConnected) { Widget _buildActions() {
return ColoredBox( return ColoredBox(
color: Theme.of(context).colorScheme.background, color: Theme.of(context).colorScheme.background,
child: SizedBox( child: SizedBox(
height: kTextTabBarHeight, height: kTextTabBarHeight,
child: BlocBuilder<DocumentScannerCubit, List<File>>( child: BlocBuilder<DocumentScannerCubit, DocumentScannerState>(
builder: (context, state) { builder: (context, state) {
return RawScrollbar( return RawScrollbar(
padding: EdgeInsets.fromLTRB(16, 0, 16, 4), padding: EdgeInsets.fromLTRB(16, 0, 16, 4),
@@ -134,12 +116,12 @@ class _ScannerPageState extends State<ScannerPage>
style: TextButton.styleFrom( style: TextButton.styleFrom(
padding: const EdgeInsets.fromLTRB(5, 10, 5, 10), padding: const EdgeInsets.fromLTRB(5, 10, 5, 10),
), ),
onPressed: state.isNotEmpty onPressed: state.scans.isNotEmpty
? () => Navigator.of(context).push( ? () => Navigator.of(context).push(
MaterialPageRoute( MaterialPageRoute(
builder: (context) => DocumentView( builder: (context) => DocumentView(
documentBytes: _assembleFileBytes( documentBytes: _assembleFileBytes(
state, state.scans,
forcePdf: true, forcePdf: true,
).then((file) => file.bytes), ).then((file) => file.bytes),
), ),
@@ -154,19 +136,32 @@ class _ScannerPageState extends State<ScannerPage>
style: TextButton.styleFrom( style: TextButton.styleFrom(
padding: const EdgeInsets.fromLTRB(5, 10, 5, 10), padding: const EdgeInsets.fromLTRB(5, 10, 5, 10),
), ),
onPressed: state.isEmpty ? null : () => _reset(context), onPressed:
state.scans.isEmpty ? null : () => _reset(context),
icon: const Icon(Icons.delete_sweep_outlined), icon: const Icon(Icons.delete_sweep_outlined),
), ),
SizedBox(width: 8), SizedBox(width: 8),
TextButton.icon( ConnectivityAwareActionWrapper(
label: Text(S.of(context)!.upload), offlineBuilder: (context, child) {
style: TextButton.styleFrom( return TextButton.icon(
padding: const EdgeInsets.fromLTRB(5, 10, 5, 10), label: Text(S.of(context)!.upload),
style: TextButton.styleFrom(
padding: const EdgeInsets.fromLTRB(5, 10, 5, 10),
),
onPressed: null,
icon: const Icon(Icons.upload_outlined),
);
},
disabled: state.scans.isEmpty,
child: TextButton.icon(
label: Text(S.of(context)!.upload),
style: TextButton.styleFrom(
padding: const EdgeInsets.fromLTRB(5, 10, 5, 10),
),
onPressed: () =>
_onPrepareDocumentUpload(context, state.scans),
icon: const Icon(Icons.upload_outlined),
), ),
onPressed: state.isEmpty || !isConnected
? null
: () => _onPrepareDocumentUpload(context),
icon: const Icon(Icons.upload_outlined),
), ),
SizedBox(width: 8), SizedBox(width: 8),
TextButton.icon( TextButton.icon(
@@ -174,7 +169,7 @@ class _ScannerPageState extends State<ScannerPage>
style: TextButton.styleFrom( style: TextButton.styleFrom(
padding: const EdgeInsets.fromLTRB(5, 10, 5, 10), padding: const EdgeInsets.fromLTRB(5, 10, 5, 10),
), ),
onPressed: state.isEmpty ? null : _onSaveToFile, onPressed: state.scans.isEmpty ? null : _onSaveToFile,
icon: const Icon(Icons.save_alt_outlined), icon: const Icon(Icons.save_alt_outlined),
), ),
SizedBox(width: 12), SizedBox(width: 12),
@@ -196,7 +191,7 @@ class _ScannerPageState extends State<ScannerPage>
final cubit = context.read<DocumentScannerCubit>(); final cubit = context.read<DocumentScannerCubit>();
final file = await _assembleFileBytes( final file = await _assembleFileBytes(
forcePdf: true, forcePdf: true,
context.read<DocumentScannerCubit>().state, context.read<DocumentScannerCubit>().state.scans,
); );
try { try {
final globalSettings = final globalSettings =
@@ -253,31 +248,27 @@ class _ScannerPageState extends State<ScannerPage>
context.read<DocumentScannerCubit>().addScan(file); context.read<DocumentScannerCubit>().addScan(file);
} }
void _onPrepareDocumentUpload(BuildContext context) async { void _onPrepareDocumentUpload(BuildContext context, List<File> scans) async {
final file = await _assembleFileBytes( final file = await _assembleFileBytes(
context.read<DocumentScannerCubit>().state, scans,
forcePdf: Hive.box<GlobalSettings>(HiveBoxes.globalSettings) forcePdf: Hive.box<GlobalSettings>(HiveBoxes.globalSettings)
.getValue()! .getValue()!
.enforceSinglePagePdfUpload, .enforceSinglePagePdfUpload,
); );
final uploadResult = await pushDocumentUploadPreparationPage( final uploadResult = await DocumentUploadRoute(
context, $extra: file.bytes,
bytes: file.bytes,
fileExtension: file.extension, fileExtension: file.extension,
); ).push<DocumentUploadResult>(context);
if ((uploadResult?.success ?? false) && uploadResult?.taskId != null) { if (uploadResult?.success ?? false) {
// For paperless version older than 1.11.3, task id will always be null! // For paperless version older than 1.11.3, task id will always be null!
context.read<DocumentScannerCubit>().reset(); context.read<DocumentScannerCubit>().reset();
context // context
.read<TaskStatusCubit>() // .read<PendingTasksNotifier>()
.listenToTaskChanges(uploadResult!.taskId!); // .listenToTaskChanges(uploadResult!.taskId!);
} }
} }
Widget _buildEmptyState(bool isConnected, List<File> scans) { Widget _buildEmptyState() {
if (scans.isNotEmpty) {
return _buildImageGrid(scans);
}
return Center( return Center(
child: Padding( child: Padding(
padding: const EdgeInsets.all(8.0), padding: const EdgeInsets.all(8.0),
@@ -293,9 +284,15 @@ class _ScannerPageState extends State<ScannerPage>
onPressed: () => _openDocumentScanner(context), onPressed: () => _openDocumentScanner(context),
), ),
Text(S.of(context)!.or), Text(S.of(context)!.or),
TextButton( ConnectivityAwareActionWrapper(
child: Text(S.of(context)!.uploadADocumentFromThisDevice), offlineBuilder: (context, child) => TextButton(
onPressed: isConnected ? _onUploadFromFilesystem : null, child: Text(S.of(context)!.uploadADocumentFromThisDevice),
onPressed: null,
),
child: TextButton(
child: Text(S.of(context)!.uploadADocumentFromThisDevice),
onPressed: _onUploadFromFilesystem,
),
), ),
], ],
), ),
@@ -323,7 +320,9 @@ class _ScannerPageState extends State<ScannerPage>
file: scans[index], file: scans[index],
onDelete: () async { onDelete: () async {
try { try {
context.read<DocumentScannerCubit>().removeScan(index); context
.read<DocumentScannerCubit>()
.removeScan(scans[index]);
} on PaperlessApiException catch (error, stackTrace) { } on PaperlessApiException catch (error, stackTrace) {
showErrorMessage(context, error, stackTrace); showErrorMessage(context, error, stackTrace);
} }
@@ -349,30 +348,34 @@ class _ScannerPageState extends State<ScannerPage>
void _onUploadFromFilesystem() async { void _onUploadFromFilesystem() async {
FilePickerResult? result = await FilePicker.platform.pickFiles( FilePickerResult? result = await FilePicker.platform.pickFiles(
type: FileType.custom, type: FileType.custom,
allowedExtensions: supportedFileExtensions, allowedExtensions:
supportedFileExtensions.map((e) => e.replaceAll(".", "")).toList(),
withData: true, withData: true,
allowMultiple: false, allowMultiple: false,
); );
if (result?.files.single.path != null) { if (result?.files.single.path != null) {
final path = result!.files.single.path!; final path = result!.files.single.path!;
final fileDescription = FileDescription.fromPath(path); final extension = p.extension(path);
final filename = p.basenameWithoutExtension(path);
File file = File(path); File file = File(path);
if (!supportedFileExtensions.contains( if (!supportedFileExtensions.contains(extension.toLowerCase())) {
fileDescription.extension.toLowerCase(),
)) {
showErrorMessage( showErrorMessage(
context, context,
const PaperlessApiException(ErrorCode.unsupportedFileFormat), const PaperlessApiException(ErrorCode.unsupportedFileFormat),
); );
return; return;
} }
pushDocumentUploadPreparationPage( DocumentUploadRoute(
context, $extra: file.readAsBytesSync(),
bytes: file.readAsBytesSync(), filename: filename,
filename: fileDescription.filename, title: filename,
title: fileDescription.filename, fileExtension: extension,
fileExtension: fileDescription.extension, ).push<DocumentUploadResult>(context);
); // if (uploadResult.success && uploadResult.taskId != null) {
// context
// .read<PendingTasksNotifier>()
// .listenToTaskChanges(uploadResult.taskId!);
// }
} }
} }
@@ -4,6 +4,7 @@ import 'package:json_annotation/json_annotation.dart';
import 'package:paperless_api/paperless_api.dart'; import 'package:paperless_api/paperless_api.dart';
import 'package:paperless_mobile/core/database/tables/local_user_app_state.dart'; import 'package:paperless_mobile/core/database/tables/local_user_app_state.dart';
import 'package:paperless_mobile/core/notifier/document_changed_notifier.dart'; import 'package:paperless_mobile/core/notifier/document_changed_notifier.dart';
import 'package:paperless_mobile/core/service/connectivity_status_service.dart';
import 'package:paperless_mobile/features/paged_document_view/cubit/document_paging_bloc_mixin.dart'; import 'package:paperless_mobile/features/paged_document_view/cubit/document_paging_bloc_mixin.dart';
import 'package:paperless_mobile/features/paged_document_view/cubit/paged_documents_state.dart'; import 'package:paperless_mobile/features/paged_document_view/cubit/paged_documents_state.dart';
import 'package:paperless_mobile/features/settings/model/view_type.dart'; import 'package:paperless_mobile/features/settings/model/view_type.dart';
@@ -15,6 +16,8 @@ class DocumentSearchCubit extends Cubit<DocumentSearchState>
with DocumentPagingBlocMixin { with DocumentPagingBlocMixin {
@override @override
final PaperlessDocumentsApi api; final PaperlessDocumentsApi api;
@override
final ConnectivityStatusService connectivityStatusService;
@override @override
final DocumentChangedNotifier notifier; final DocumentChangedNotifier notifier;
@@ -24,8 +27,11 @@ class DocumentSearchCubit extends Cubit<DocumentSearchState>
this.api, this.api,
this.notifier, this.notifier,
this._userAppState, this._userAppState,
) : super(DocumentSearchState( this.connectivityStatusService,
searchHistory: _userAppState.documentSearchHistory)) { ) : super(
DocumentSearchState(
searchHistory: _userAppState.documentSearchHistory),
) {
notifier.addListener( notifier.addListener(
this, this,
onDeleted: remove, onDeleted: remove,
@@ -34,22 +40,25 @@ class DocumentSearchCubit extends Cubit<DocumentSearchState>
} }
Future<void> search(String query) async { Future<void> search(String query) async {
emit(state.copyWith( final normalizedQuery = query.trim();
isLoading: true, emit(
suggestions: [], state.copyWith(
view: SearchView.results, isLoading: true,
)); suggestions: [],
view: SearchView.results,
),
);
final searchFilter = DocumentFilter( final searchFilter = DocumentFilter(
query: TextQuery.extended(query), query: TextQuery.extended(normalizedQuery),
); );
await updateFilter(filter: searchFilter); await updateFilter(filter: searchFilter);
emit( emit(
state.copyWith( state.copyWith(
searchHistory: [ searchHistory: [
query, normalizedQuery,
...state.searchHistory ...state.searchHistory
.whereNot((previousQuery) => previousQuery == query) .whereNot((previousQuery) => previousQuery == normalizedQuery)
], ],
), ),
); );
@@ -1,19 +1,15 @@
import 'package:animations/animations.dart'; import 'package:animations/animations.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter_cache_manager/flutter_cache_manager.dart';
import 'package:hive_flutter/adapters.dart'; import 'package:hive_flutter/adapters.dart';
import 'package:paperless_api/paperless_api.dart';
import 'package:paperless_mobile/core/config/hive/hive_config.dart'; import 'package:paperless_mobile/core/config/hive/hive_config.dart';
import 'package:paperless_mobile/core/database/tables/local_user_account.dart'; import 'package:paperless_mobile/core/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_app_state.dart';
import 'package:paperless_mobile/core/repository/label_repository.dart';
import 'package:paperless_mobile/core/repository/user_repository.dart';
import 'package:paperless_mobile/features/document_search/cubit/document_search_cubit.dart'; import 'package:paperless_mobile/features/document_search/cubit/document_search_cubit.dart';
import 'package:paperless_mobile/features/document_search/view/document_search_page.dart'; import 'package:paperless_mobile/features/document_search/view/document_search_page.dart';
import 'package:paperless_mobile/features/home/view/model/api_version.dart';
import 'package:paperless_mobile/features/settings/view/manage_accounts_page.dart'; import 'package:paperless_mobile/features/settings/view/manage_accounts_page.dart';
import 'package:paperless_mobile/features/settings/view/widgets/global_settings_builder.dart';
import 'package:paperless_mobile/features/settings/view/widgets/user_avatar.dart'; import 'package:paperless_mobile/features/settings/view/widgets/user_avatar.dart';
import 'package:paperless_mobile/features/sharing/cubit/receive_share_cubit.dart';
import 'package:paperless_mobile/features/tasks/model/pending_tasks_notifier.dart';
import 'package:paperless_mobile/generated/l10n/app_localizations.dart'; import 'package:paperless_mobile/generated/l10n/app_localizations.dart';
import 'package:provider/provider.dart'; import 'package:provider/provider.dart';
@@ -27,112 +23,96 @@ class DocumentSearchBar extends StatefulWidget {
class _DocumentSearchBarState extends State<DocumentSearchBar> { class _DocumentSearchBarState extends State<DocumentSearchBar> {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return Container( return OpenContainer(
margin: EdgeInsets.only(top: 8), transitionDuration: const Duration(milliseconds: 200),
child: OpenContainer( transitionType: ContainerTransitionType.fadeThrough,
transitionDuration: const Duration(milliseconds: 200), closedElevation: 1,
transitionType: ContainerTransitionType.fadeThrough, middleColor: Theme.of(context).colorScheme.surfaceVariant,
closedElevation: 1, openColor: Theme.of(context).colorScheme.background,
middleColor: Theme.of(context).colorScheme.surfaceVariant, closedColor: Theme.of(context).colorScheme.surfaceVariant,
openColor: Theme.of(context).colorScheme.background, closedShape: RoundedRectangleBorder(
closedColor: Theme.of(context).colorScheme.surfaceVariant, borderRadius: BorderRadius.circular(56),
closedShape: RoundedRectangleBorder( ),
borderRadius: BorderRadius.circular(56), closedBuilder: (_, action) {
), return InkWell(
closedBuilder: (_, action) { onTap: action,
return InkWell( child: ConstrainedBox(
onTap: action, constraints: const BoxConstraints(
child: ConstrainedBox( maxWidth: 720,
constraints: const BoxConstraints( minWidth: 360,
maxWidth: 720, maxHeight: 56,
minWidth: 360, minHeight: 48,
maxHeight: 56, ),
minHeight: 48, child: Row(
), mainAxisAlignment: MainAxisAlignment.spaceBetween,
child: Row( children: [
mainAxisAlignment: MainAxisAlignment.spaceBetween, Flexible(
children: [ child: Padding(
Flexible( padding: const EdgeInsets.symmetric(horizontal: 8),
child: Padding( child: Row(
padding: const EdgeInsets.symmetric(horizontal: 8), crossAxisAlignment: CrossAxisAlignment.center,
child: Row( children: [
crossAxisAlignment: CrossAxisAlignment.center, IconButton(
children: [ icon: ListenableBuilder(
IconButton( listenable:
icon: const Icon(Icons.menu), context.read<ConsumptionChangeNotifier>(),
onPressed: Scaffold.of(context).openDrawer, builder: (context, child) {
return Badge(
isLabelVisible: context
.read<ConsumptionChangeNotifier>()
.pendingFiles
.isNotEmpty,
child: const Icon(Icons.menu),
backgroundColor: Colors.red,
smallSize: 8,
);
},
), ),
Flexible( onPressed: Scaffold.of(context).openDrawer,
child: Text( ),
S.of(context)!.searchDocuments, Flexible(
style: Theme.of(context) child: Text(
.textTheme S.of(context)!.searchDocuments,
.bodyLarge style:
?.copyWith( Theme.of(context).textTheme.bodyLarge?.copyWith(
fontWeight: FontWeight.w500, fontWeight: FontWeight.w500,
color: Theme.of(context).hintColor, color: Theme.of(context).hintColor,
), ),
),
), ),
], ),
), ],
), ),
), ),
_buildUserAvatar(context), ),
], _buildUserAvatar(context),
), ],
), ),
); ),
}, );
openBuilder: (_, action) { },
return MultiProvider( openBuilder: (_, action) {
providers: [ return Provider(
Provider.value(value: context.read<LabelRepository>()), create: (_) => DocumentSearchCubit(
Provider.value(value: context.read<PaperlessDocumentsApi>()), context.read(),
Provider.value(value: context.read<CacheManager>()), context.read(),
Provider.value(value: context.read<ApiVersion>()), Hive.box<LocalUserAppState>(HiveBoxes.localUserAppState)
if (context.read<ApiVersion>().hasMultiUserSupport) .get(context.read<LocalUserAccount>().id)!,
Provider.value(value: context.read<UserRepository>()), context.read(),
], ),
child: Provider( child: const DocumentSearchPage(),
create: (_) => DocumentSearchCubit( );
context.read(), },
context.read(),
Hive.box<LocalUserAppState>(HiveBoxes.localUserAppState)
.get(LocalUserAccount.current.id)!,
),
builder: (_, __) => const DocumentSearchPage(),
),
);
},
),
); );
} }
IconButton _buildUserAvatar(BuildContext context) { IconButton _buildUserAvatar(BuildContext context) {
return IconButton( return IconButton(
padding: const EdgeInsets.all(6), padding: const EdgeInsets.all(6),
icon: GlobalSettingsBuilder( icon: UserAvatar(account: context.watch<LocalUserAccount>()),
builder: (context, settings) {
return ValueListenableBuilder(
valueListenable:
Hive.box<LocalUserAccount>(HiveBoxes.localUserAccount)
.listenable(),
builder: (context, box, _) {
final account = box.get(settings.currentLoggedInUser!)!;
return UserAvatar(account: account);
},
);
},
),
onPressed: () { onPressed: () {
final apiVersion = context.read<ApiVersion>();
showDialog( showDialog(
context: context, context: context,
builder: (context) => Provider.value( builder: (_) => const ManageAccountsPage(),
value: apiVersion,
child: const ManageAccountsPage(),
),
); );
}, },
); );
@@ -4,13 +4,13 @@ import 'dart:math' as math;
import 'package:collection/collection.dart'; import 'package:collection/collection.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:paperless_mobile/core/navigation/push_routes.dart';
import 'package:paperless_mobile/extensions/flutter_extensions.dart'; import 'package:paperless_mobile/extensions/flutter_extensions.dart';
import 'package:paperless_mobile/features/document_search/cubit/document_search_cubit.dart'; import 'package:paperless_mobile/features/document_search/cubit/document_search_cubit.dart';
import 'package:paperless_mobile/features/document_search/view/remove_history_entry_dialog.dart'; import 'package:paperless_mobile/features/document_search/view/remove_history_entry_dialog.dart';
import 'package:paperless_mobile/features/documents/view/widgets/adaptive_documents_view.dart'; import 'package:paperless_mobile/features/documents/view/widgets/adaptive_documents_view.dart';
import 'package:paperless_mobile/features/documents/view/widgets/selection/view_type_selection_widget.dart'; import 'package:paperless_mobile/features/documents/view/widgets/selection/view_type_selection_widget.dart';
import 'package:paperless_mobile/generated/l10n/app_localizations.dart'; import 'package:paperless_mobile/generated/l10n/app_localizations.dart';
import 'package:paperless_mobile/routes/typed/branches/documents_route.dart';
class DocumentSearchPage extends StatefulWidget { class DocumentSearchPage extends StatefulWidget {
const DocumentSearchPage({super.key}); const DocumentSearchPage({super.key});
@@ -186,7 +186,7 @@ class _DocumentSearchPageState extends State<DocumentSearchPage> {
children: [ children: [
Text( Text(
S.of(context)!.results, S.of(context)!.results,
style: Theme.of(context).textTheme.bodySmall, style: Theme.of(context).textTheme.labelMedium,
), ),
BlocBuilder<DocumentSearchCubit, DocumentSearchState>( BlocBuilder<DocumentSearchCubit, DocumentSearchState>(
builder: (context, state) { builder: (context, state) {
@@ -198,15 +198,15 @@ class _DocumentSearchPageState extends State<DocumentSearchPage> {
}, },
) )
], ],
).padded(); ).paddedLTRB(16, 8, 8, 8);
return CustomScrollView( return CustomScrollView(
slivers: [ slivers: [
SliverToBoxAdapter(child: header), SliverToBoxAdapter(child: header),
if (state.hasLoaded && !state.isLoading && state.documents.isEmpty) if (state.hasLoaded && !state.isLoading && state.documents.isEmpty)
SliverToBoxAdapter( SliverToBoxAdapter(
child: Center( child: Center(
child: Text(S.of(context)!.noMatchesFound), child: Text(S.of(context)!.noDocumentsFound),
), ).paddedOnly(top: 8),
) )
else else
SliverAdaptiveDocumentsView( SliverAdaptiveDocumentsView(
@@ -218,11 +218,8 @@ class _DocumentSearchPageState extends State<DocumentSearchPage> {
hasLoaded: state.hasLoaded, hasLoaded: state.hasLoaded,
enableHeroAnimation: false, enableHeroAnimation: false,
onTap: (document) { onTap: (document) {
pushDocumentDetailsRoute( DocumentDetailsRoute($extra: document, isLabelClickable: false)
context, .push(context);
document: document,
isLabelClickable: false,
);
}, },
) )
], ],
@@ -1,12 +1,9 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:hive/hive.dart';
import 'package:hive_flutter/adapters.dart'; import 'package:hive_flutter/adapters.dart';
import 'package:paperless_api/paperless_api.dart'; import 'package:paperless_api/paperless_api.dart';
import 'package:paperless_mobile/core/config/hive/hive_config.dart'; import 'package:paperless_mobile/core/config/hive/hive_config.dart';
import 'package:paperless_mobile/core/database/tables/local_user_account.dart'; import 'package:paperless_mobile/core/database/tables/local_user_account.dart';
import 'package:paperless_mobile/features/document_search/view/document_search_bar.dart'; import 'package:paperless_mobile/features/document_search/view/document_search_bar.dart';
import 'package:paperless_mobile/features/home/view/model/api_version.dart';
import 'package:paperless_mobile/features/settings/view/manage_accounts_page.dart'; import 'package:paperless_mobile/features/settings/view/manage_accounts_page.dart';
import 'package:paperless_mobile/features/settings/view/widgets/global_settings_builder.dart'; import 'package:paperless_mobile/features/settings/view/widgets/global_settings_builder.dart';
import 'package:paperless_mobile/features/settings/view/widgets/user_avatar.dart'; import 'package:paperless_mobile/features/settings/view/widgets/user_avatar.dart';
@@ -25,14 +22,11 @@ class SliverSearchBar extends StatelessWidget {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
if (LocalUserAccount.current.paperlessUser.canViewDocuments) { if (context.watch<LocalUserAccount>().paperlessUser.canViewDocuments) {
return SliverAppBar( return const SliverAppBar(
toolbarHeight: kToolbarHeight, titleSpacing: 8,
flexibleSpace: Container(
margin: const EdgeInsets.symmetric(horizontal: 16.0),
child: const DocumentSearchBar(),
),
automaticallyImplyLeading: false, automaticallyImplyLeading: false,
title: DocumentSearchBar(),
); );
} else { } else {
return SliverAppBar( return SliverAppBar(
@@ -49,18 +43,17 @@ class SliverSearchBar extends StatelessWidget {
Hive.box<LocalUserAccount>(HiveBoxes.localUserAccount) Hive.box<LocalUserAccount>(HiveBoxes.localUserAccount)
.listenable(), .listenable(),
builder: (context, box, _) { builder: (context, box, _) {
final account = box.get(settings.currentLoggedInUser!)!; final account = box.get(settings.loggedInUserId!)!;
return UserAvatar(account: account); return UserAvatar(account: account);
}, },
); );
}, },
), ),
onPressed: () { onPressed: () {
final apiVersion = context.read<ApiVersion>();
showDialog( showDialog(
context: context, context: context,
builder: (context) => Provider.value( builder: (_) => Provider.value(
value: apiVersion, value: context.read<LocalUserAccount>(),
child: const ManageAccountsPage(), child: const ManageAccountsPage(),
), ),
); );
@@ -1,24 +1,26 @@
import 'dart:async'; import 'dart:async';
import 'package:bloc/bloc.dart'; import 'package:bloc/bloc.dart';
import 'package:connectivity_plus/connectivity_plus.dart';
import 'package:equatable/equatable.dart'; import 'package:equatable/equatable.dart';
import 'package:flutter/foundation.dart'; import 'package:flutter/foundation.dart';
import 'package:paperless_api/paperless_api.dart'; import 'package:paperless_api/paperless_api.dart';
import 'package:paperless_mobile/core/repository/label_repository.dart'; import 'package:paperless_mobile/core/repository/label_repository.dart';
import 'package:paperless_mobile/core/service/connectivity_status_service.dart';
import 'package:paperless_mobile/features/tasks/model/pending_tasks_notifier.dart';
part 'document_upload_state.dart'; part 'document_upload_state.dart';
class DocumentUploadCubit extends Cubit<DocumentUploadState> { class DocumentUploadCubit extends Cubit<DocumentUploadState> {
final PaperlessDocumentsApi _documentApi; final PaperlessDocumentsApi _documentApi;
final PendingTasksNotifier _tasksNotifier;
final LabelRepository _labelRepository; final LabelRepository _labelRepository;
final Connectivity _connectivity; final ConnectivityStatusService _connectivityStatusService;
DocumentUploadCubit( DocumentUploadCubit(
this._labelRepository, this._labelRepository,
this._documentApi, this._documentApi,
this._connectivity, this._connectivityStatusService,
this._tasksNotifier,
) : super(const DocumentUploadState()) { ) : super(const DocumentUploadState()) {
_labelRepository.addListener( _labelRepository.addListener(
this, this,
@@ -43,7 +45,7 @@ class DocumentUploadCubit extends Cubit<DocumentUploadState> {
DateTime? createdAt, DateTime? createdAt,
int? asn, int? asn,
}) async { }) async {
return await _documentApi.create( final taskId = await _documentApi.create(
bytes, bytes,
filename: filename, filename: filename,
title: title, title: title,
@@ -53,6 +55,10 @@ class DocumentUploadCubit extends Cubit<DocumentUploadState> {
createdAt: createdAt, createdAt: createdAt,
asn: asn, asn: asn,
); );
if (taskId != null) {
_tasksNotifier.listenToTaskChanges(taskId);
}
return taskId;
} }
@override @override
@@ -1,10 +1,11 @@
import 'dart:async';
import 'dart:typed_data'; import 'dart:typed_data';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:flutter_form_builder/flutter_form_builder.dart'; import 'package:flutter_form_builder/flutter_form_builder.dart';
import 'package:go_router/go_router.dart';
import 'package:hive/hive.dart'; import 'package:hive/hive.dart';
import 'package:intl/date_symbol_data_local.dart'; import 'package:intl/date_symbol_data_local.dart';
import 'package:intl/intl.dart'; import 'package:intl/intl.dart';
import 'package:paperless_api/paperless_api.dart'; import 'package:paperless_api/paperless_api.dart';
@@ -12,6 +13,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/global_settings.dart';
import 'package:paperless_mobile/core/database/tables/local_user_account.dart'; import 'package:paperless_mobile/core/database/tables/local_user_account.dart';
import 'package:paperless_mobile/core/repository/label_repository.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/extensions/flutter_extensions.dart';
import 'package:paperless_mobile/features/document_upload/cubit/document_upload_cubit.dart'; import 'package:paperless_mobile/features/document_upload/cubit/document_upload_cubit.dart';
import 'package:paperless_mobile/features/edit_label/view/impl/add_correspondent_page.dart'; import 'package:paperless_mobile/features/edit_label/view/impl/add_correspondent_page.dart';
@@ -19,8 +21,8 @@ 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/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/tags/view/widgets/tags_form_field.dart';
import 'package:paperless_mobile/features/labels/view/widgets/label_form_field.dart'; import 'package:paperless_mobile/features/labels/view/widgets/label_form_field.dart';
import 'package:paperless_mobile/features/sharing/view/widgets/file_thumbnail.dart';
import 'package:paperless_mobile/generated/l10n/app_localizations.dart'; import 'package:paperless_mobile/generated/l10n/app_localizations.dart';
import 'package:paperless_mobile/helpers/message_helpers.dart'; import 'package:paperless_mobile/helpers/message_helpers.dart';
import 'package:provider/provider.dart'; import 'package:provider/provider.dart';
@@ -32,7 +34,7 @@ class DocumentUploadResult {
} }
class DocumentUploadPreparationPage extends StatefulWidget { class DocumentUploadPreparationPage extends StatefulWidget {
final Uint8List fileBytes; final FutureOr<Uint8List> fileBytes;
final String? title; final String? title;
final String? filename; final String? filename;
final String? fileExtension; final String? fileExtension;
@@ -56,7 +58,6 @@ class _DocumentUploadPreparationPageState
static final fileNameDateFormat = DateFormat("yyyy_MM_ddTHH_mm_ss"); static final fileNameDateFormat = DateFormat("yyyy_MM_ddTHH_mm_ss");
final GlobalKey<FormBuilderState> _formKey = GlobalKey(); final GlobalKey<FormBuilderState> _formKey = GlobalKey();
Map<String, String> _errors = {}; Map<String, String> _errors = {};
bool _isUploadLoading = false; bool _isUploadLoading = false;
late bool _syncTitleAndFilename; late bool _syncTitleAndFilename;
@@ -73,18 +74,12 @@ class _DocumentUploadPreparationPageState
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return Scaffold( return Scaffold(
extendBodyBehindAppBar: false,
resizeToAvoidBottomInset: true, resizeToAvoidBottomInset: true,
appBar: AppBar(
title: Text(S.of(context)!.prepareDocument),
bottom: _isUploadLoading
? const PreferredSize(
child: LinearProgressIndicator(),
preferredSize: Size.fromHeight(4.0))
: null,
),
floatingActionButton: Visibility( floatingActionButton: Visibility(
visible: MediaQuery.of(context).viewInsets.bottom == 0, visible: MediaQuery.of(context).viewInsets.bottom == 0,
child: FloatingActionButton.extended( child: FloatingActionButton.extended(
heroTag: "fab_document_upload",
onPressed: _onSubmit, onPressed: _onSubmit,
label: Text(S.of(context)!.upload), label: Text(S.of(context)!.upload),
icon: const Icon(Icons.upload), icon: const Icon(Icons.upload),
@@ -94,174 +89,249 @@ class _DocumentUploadPreparationPageState
builder: (context, state) { builder: (context, state) {
return FormBuilder( return FormBuilder(
key: _formKey, key: _formKey,
child: ListView( child: NestedScrollView(
children: [ headerSliverBuilder: (context, innerBoxIsScrolled) => [
// Title SliverOverlapAbsorber(
FormBuilderTextField( handle:
autovalidateMode: AutovalidateMode.always, NestedScrollView.sliverOverlapAbsorberHandleFor(context),
name: DocumentModel.titleKey, sliver: SliverAppBar(
initialValue: leading: BackButton(),
widget.title ?? "scan_${fileNameDateFormat.format(_now)}", pinned: true,
validator: (value) { expandedHeight: 150,
if (value?.trim().isEmpty ?? true) { flexibleSpace: FlexibleSpaceBar(
return S.of(context)!.thisFieldIsRequired; background: FutureOrBuilder<Uint8List>(
} future: widget.fileBytes,
return null; builder: (context, snapshot) {
}, if (!snapshot.hasData) {
decoration: InputDecoration( return SizedBox.shrink();
labelText: S.of(context)!.title, }
suffixIcon: IconButton( return FileThumbnail(
icon: const Icon(Icons.close), bytes: snapshot.data!,
onPressed: () { fit: BoxFit.fitWidth,
_formKey.currentState?.fields[DocumentModel.titleKey] width: MediaQuery.sizeOf(context).width,
?.didChange(""); );
if (_syncTitleAndFilename) { },
_formKey.currentState?.fields[fkFileName] ),
?.didChange(""); title: Text(S.of(context)!.prepareDocument),
} collapseMode: CollapseMode.pin,
},
), ),
errorText: _errors[DocumentModel.titleKey], bottom: _isUploadLoading
), ? PreferredSize(
onChanged: (value) { child: LinearProgressIndicator(),
final String transformedValue = preferredSize: Size.fromHeight(4.0),
_formatFilename(value ?? '');
if (_syncTitleAndFilename) {
_formKey.currentState?.fields[fkFileName]
?.didChange(transformedValue);
}
},
),
// Filename
FormBuilderTextField(
autovalidateMode: AutovalidateMode.always,
readOnly: _syncTitleAndFilename,
enabled: !_syncTitleAndFilename,
name: fkFileName,
decoration: InputDecoration(
labelText: S.of(context)!.fileName,
suffixText: widget.fileExtension,
suffixIcon: IconButton(
icon: const Icon(Icons.clear),
onPressed: () => _formKey.currentState?.fields[fkFileName]
?.didChange(''),
),
),
initialValue: widget.filename ??
"scan_${fileNameDateFormat.format(_now)}",
),
// Synchronize title and filename
SwitchListTile(
value: _syncTitleAndFilename,
onChanged: (value) {
setState(
() => _syncTitleAndFilename = value,
);
if (_syncTitleAndFilename) {
final String transformedValue = _formatFilename(_formKey
.currentState
?.fields[DocumentModel.titleKey]
?.value as String);
if (_syncTitleAndFilename) {
_formKey.currentState?.fields[fkFileName]
?.didChange(transformedValue);
}
}
},
title: Text(
S.of(context)!.synchronizeTitleAndFilename,
),
),
// Created at
FormBuilderDateTimePicker(
autovalidateMode: AutovalidateMode.always,
format: DateFormat.yMMMMd(),
inputType: InputType.date,
name: DocumentModel.createdKey,
initialValue: null,
onChanged: (value) {
setState(() => _showDatePickerDeleteIcon = value != null);
},
decoration: InputDecoration(
prefixIcon: const Icon(Icons.calendar_month_outlined),
labelText: S.of(context)!.createdAt + " *",
suffixIcon: _showDatePickerDeleteIcon
? IconButton(
icon: const Icon(Icons.close),
onPressed: () {
_formKey.currentState!
.fields[DocumentModel.createdKey]
?.didChange(null);
},
) )
: null, : null,
), ),
), ),
// Correspondent ],
if (LocalUserAccount body: Padding(
.current.paperlessUser.canViewCorrespondents) padding: const EdgeInsets.only(top: 16.0),
LabelFormField<Correspondent>( child: Builder(
showAnyAssignedOption: false, builder: (context) {
showNotAssignedOption: false, return CustomScrollView(
addLabelPageBuilder: (initialName) => MultiProvider( slivers: [
providers: [ SliverOverlapInjector(
Provider.value( handle:
value: context.read<LabelRepository>(), NestedScrollView.sliverOverlapAbsorberHandleFor(
context),
), ),
Provider.value( SliverList.list(
value: context.read<ApiVersion>(), children: [
) // Title
], FormBuilderTextField(
child: AddCorrespondentPage(initialName: initialName), autovalidateMode: AutovalidateMode.always,
), name: DocumentModel.titleKey,
addLabelText: S.of(context)!.addCorrespondent, initialValue: widget.title ??
labelText: S.of(context)!.correspondent + " *", "scan_${fileNameDateFormat.format(_now)}",
name: DocumentModel.correspondentKey, validator: (value) {
options: state.correspondents, if (value?.trim().isEmpty ?? true) {
prefixIcon: const Icon(Icons.person_outline), return S.of(context)!.thisFieldIsRequired;
allowSelectUnassigned: true, }
canCreateNewLabel: LocalUserAccount return null;
.current.paperlessUser.canCreateCorrespondents, },
), decoration: InputDecoration(
// Document type labelText: S.of(context)!.title,
if (LocalUserAccount.current.paperlessUser.canViewDocumentTypes) suffixIcon: IconButton(
LabelFormField<DocumentType>( icon: const Icon(Icons.close),
showAnyAssignedOption: false, onPressed: () {
showNotAssignedOption: false, _formKey.currentState
addLabelPageBuilder: (initialName) => MultiProvider( ?.fields[DocumentModel.titleKey]
providers: [ ?.didChange("");
Provider.value( if (_syncTitleAndFilename) {
value: context.read<LabelRepository>(), _formKey.currentState?.fields[fkFileName]
?.didChange("");
}
},
),
errorText: _errors[DocumentModel.titleKey],
),
onChanged: (value) {
final String transformedValue =
_formatFilename(value ?? '');
if (_syncTitleAndFilename) {
_formKey.currentState?.fields[fkFileName]
?.didChange(transformedValue);
}
},
),
// Filename
FormBuilderTextField(
autovalidateMode: AutovalidateMode.always,
readOnly: _syncTitleAndFilename,
enabled: !_syncTitleAndFilename,
name: fkFileName,
decoration: InputDecoration(
labelText: S.of(context)!.fileName,
suffixText: widget.fileExtension,
suffixIcon: IconButton(
icon: const Icon(Icons.clear),
onPressed: () => _formKey
.currentState?.fields[fkFileName]
?.didChange(''),
),
),
initialValue: widget.filename ??
"scan_${fileNameDateFormat.format(_now)}",
),
// Synchronize title and filename
SwitchListTile(
value: _syncTitleAndFilename,
onChanged: (value) {
setState(
() => _syncTitleAndFilename = value,
);
if (_syncTitleAndFilename) {
final String transformedValue =
_formatFilename(_formKey
.currentState
?.fields[DocumentModel.titleKey]
?.value as String);
if (_syncTitleAndFilename) {
_formKey.currentState?.fields[fkFileName]
?.didChange(transformedValue);
}
}
},
title: Text(
S.of(context)!.synchronizeTitleAndFilename,
),
),
// Created at
FormBuilderDateTimePicker(
autovalidateMode: AutovalidateMode.always,
format: DateFormat.yMMMMd(),
inputType: InputType.date,
name: DocumentModel.createdKey,
initialValue: null,
onChanged: (value) {
setState(() =>
_showDatePickerDeleteIcon = value != null);
},
decoration: InputDecoration(
prefixIcon:
const Icon(Icons.calendar_month_outlined),
labelText: S.of(context)!.createdAt + " *",
suffixIcon: _showDatePickerDeleteIcon
? IconButton(
icon: const Icon(Icons.close),
onPressed: () {
_formKey.currentState!
.fields[DocumentModel.createdKey]
?.didChange(null);
},
)
: null,
),
),
// Correspondent
if (context
.watch<LocalUserAccount>()
.paperlessUser
.canViewCorrespondents)
LabelFormField<Correspondent>(
showAnyAssignedOption: false,
showNotAssignedOption: false,
addLabelPageBuilder: (initialName) =>
MultiProvider(
providers: [
Provider.value(
value: context.read<LabelRepository>(),
),
Provider.value(
value: context.read<ApiVersion>(),
)
],
child: AddCorrespondentPage(
initialName: initialName),
),
addLabelText: S.of(context)!.addCorrespondent,
labelText: S.of(context)!.correspondent + " *",
name: DocumentModel.correspondentKey,
options: state.correspondents,
prefixIcon: const Icon(Icons.person_outline),
allowSelectUnassigned: true,
canCreateNewLabel: context
.watch<LocalUserAccount>()
.paperlessUser
.canCreateCorrespondents,
),
// Document type
if (context
.watch<LocalUserAccount>()
.paperlessUser
.canViewDocumentTypes)
LabelFormField<DocumentType>(
showAnyAssignedOption: false,
showNotAssignedOption: false,
addLabelPageBuilder: (initialName) =>
MultiProvider(
providers: [
Provider.value(
value: context.read<LabelRepository>(),
),
Provider.value(
value: context.read<ApiVersion>(),
)
],
child: AddDocumentTypePage(
initialName: initialName),
),
addLabelText: S.of(context)!.addDocumentType,
labelText: S.of(context)!.documentType + " *",
name: DocumentModel.documentTypeKey,
options: state.documentTypes,
prefixIcon:
const Icon(Icons.description_outlined),
allowSelectUnassigned: true,
canCreateNewLabel: context
.watch<LocalUserAccount>()
.paperlessUser
.canCreateDocumentTypes,
),
if (context
.watch<LocalUserAccount>()
.paperlessUser
.canViewTags)
TagsFormField(
name: DocumentModel.tagsKey,
allowCreation: true,
allowExclude: false,
allowOnlySelection: true,
options: state.tags,
),
Text(
"* " + S.of(context)!.uploadInferValuesHint,
style: Theme.of(context).textTheme.bodySmall,
textAlign: TextAlign.justify,
).padded(),
const SizedBox(height: 300),
].padded(),
), ),
Provider.value(
value: context.read<ApiVersion>(),
)
], ],
child: AddDocumentTypePage(initialName: initialName), );
), },
addLabelText: S.of(context)!.addDocumentType,
labelText: S.of(context)!.documentType + " *",
name: DocumentModel.documentTypeKey,
options: state.documentTypes,
prefixIcon: const Icon(Icons.description_outlined),
allowSelectUnassigned: true,
canCreateNewLabel: LocalUserAccount
.current.paperlessUser.canCreateDocumentTypes,
),
if (LocalUserAccount.current.paperlessUser.canViewTags)
TagsFormField(
name: DocumentModel.tagsKey,
allowCreation: true,
allowExclude: false,
allowOnlySelection: true,
options: state.tags,
),
Text(
"* " + S.of(context)!.uploadInferValuesHint,
style: Theme.of(context).textTheme.bodySmall,
), ),
const SizedBox(height: 300), ),
].padded(),
), ),
); );
}, },
@@ -289,14 +359,14 @@ class _DocumentUploadPreparationPageState
?.whenOrNull(fromId: (id) => id); ?.whenOrNull(fromId: (id) => id);
final asn = fv[DocumentModel.asnKey] as int?; final asn = fv[DocumentModel.asnKey] as int?;
final taskId = await cubit.upload( final taskId = await cubit.upload(
widget.fileBytes, await widget.fileBytes,
filename: _padWithExtension( filename: _padWithExtension(
_formKey.currentState?.value[fkFileName], _formKey.currentState?.value[fkFileName],
widget.fileExtension, widget.fileExtension,
), ),
userId: Hive.box<GlobalSettings>(HiveBoxes.globalSettings) userId: Hive.box<GlobalSettings>(HiveBoxes.globalSettings)
.getValue()! .getValue()!
.currentLoggedInUser!, .loggedInUserId!,
title: title, title: title,
documentType: docType, documentType: docType,
correspondent: correspondent, correspondent: correspondent,
@@ -308,10 +378,7 @@ class _DocumentUploadPreparationPageState
context, context,
S.of(context)!.documentSuccessfullyUploadedProcessing, S.of(context)!.documentSuccessfullyUploadedProcessing,
); );
Navigator.pop( context.pop(DocumentUploadResult(true, taskId));
context,
DocumentUploadResult(true, taskId),
);
} on PaperlessApiException catch (error, stackTrace) { } on PaperlessApiException catch (error, stackTrace) {
showErrorMessage(context, error, stackTrace); showErrorMessage(context, error, stackTrace);
} on PaperlessFormValidationException catch (exception) { } on PaperlessFormValidationException catch (exception) {
@@ -336,4 +403,33 @@ class _DocumentUploadPreparationPageState
String _formatFilename(String source) { String _formatFilename(String source) {
return source.replaceAll(RegExp(r"[\W_]"), "_").toLowerCase(); return source.replaceAll(RegExp(r"[\W_]"), "_").toLowerCase();
} }
// Future<Color> _computeAverageColor() async {
// final bitmap = img.decodeImage(await widget.fileBytes);
// if (bitmap == null) {
// return Colors.black;
// }
// int redBucket = 0;
// int greenBucket = 0;
// int blueBucket = 0;
// int pixelCount = 0;
// for (int y = 0; y < bitmap.height; y++) {
// for (int x = 0; x < bitmap.width; x++) {
// final c = bitmap.getPixel(x, y);
// pixelCount++;
// redBucket += c.r.toInt();
// greenBucket += c.g.toInt();
// blueBucket += c.b.toInt();
// }
// }
// return Color.fromRGBO(
// redBucket ~/ pixelCount,
// greenBucket ~/ pixelCount,
// blueBucket ~/ pixelCount,
// 1,
// );
// }
} }
@@ -7,6 +7,7 @@ import 'package:paperless_api/paperless_api.dart';
import 'package:paperless_mobile/core/database/tables/local_user_app_state.dart'; import 'package:paperless_mobile/core/database/tables/local_user_app_state.dart';
import 'package:paperless_mobile/core/notifier/document_changed_notifier.dart'; import 'package:paperless_mobile/core/notifier/document_changed_notifier.dart';
import 'package:paperless_mobile/core/repository/label_repository.dart'; import 'package:paperless_mobile/core/repository/label_repository.dart';
import 'package:paperless_mobile/core/service/connectivity_status_service.dart';
import 'package:paperless_mobile/features/paged_document_view/cubit/document_paging_bloc_mixin.dart'; import 'package:paperless_mobile/features/paged_document_view/cubit/document_paging_bloc_mixin.dart';
import 'package:paperless_mobile/features/paged_document_view/cubit/paged_documents_state.dart'; import 'package:paperless_mobile/features/paged_document_view/cubit/paged_documents_state.dart';
import 'package:paperless_mobile/features/settings/model/view_type.dart'; import 'package:paperless_mobile/features/settings/model/view_type.dart';
@@ -20,6 +21,8 @@ class DocumentsCubit extends HydratedCubit<DocumentsState>
final PaperlessDocumentsApi api; final PaperlessDocumentsApi api;
final LabelRepository _labelRepository; final LabelRepository _labelRepository;
@override
final ConnectivityStatusService connectivityStatusService;
@override @override
final DocumentChangedNotifier notifier; final DocumentChangedNotifier notifier;
@@ -31,6 +34,7 @@ class DocumentsCubit extends HydratedCubit<DocumentsState>
this.notifier, this.notifier,
this._labelRepository, this._labelRepository,
this._userState, this._userState,
this.connectivityStatusService,
) : super(DocumentsState( ) : super(DocumentsState(
filter: _userState.currentDocumentFilter, filter: _userState.currentDocumentFilter,
viewType: _userState.documentsPageViewType, viewType: _userState.documentsPageViewType,
@@ -1,28 +1,31 @@
import 'package:badges/badges.dart' as b;
import 'package:collection/collection.dart'; import 'package:collection/collection.dart';
import 'package:defer_pointer/defer_pointer.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:paperless_api/paperless_api.dart'; import 'package:paperless_api/paperless_api.dart';
import 'package:paperless_mobile/core/bloc/connectivity_cubit.dart'; import 'package:paperless_mobile/core/bloc/connectivity_cubit.dart';
import 'package:paperless_mobile/core/database/tables/local_user_account.dart'; import 'package:paperless_mobile/core/database/tables/local_user_account.dart';
import 'package:paperless_mobile/core/delegate/customizable_sliver_persistent_header_delegate.dart';
import 'package:paperless_mobile/core/navigation/push_routes.dart';
import 'package:paperless_mobile/core/widgets/material/colored_tab_bar.dart';
import 'package:paperless_mobile/extensions/flutter_extensions.dart'; import 'package:paperless_mobile/extensions/flutter_extensions.dart';
import 'package:paperless_mobile/features/app_drawer/view/app_drawer.dart'; import 'package:paperless_mobile/features/app_drawer/view/app_drawer.dart';
import 'package:paperless_mobile/features/document_search/view/sliver_search_bar.dart'; import 'package:paperless_mobile/features/document_search/view/sliver_search_bar.dart';
import 'package:paperless_mobile/features/documents/cubit/documents_cubit.dart'; import 'package:paperless_mobile/features/documents/cubit/documents_cubit.dart';
import 'package:paperless_mobile/features/documents/view/widgets/adaptive_documents_view.dart'; import 'package:paperless_mobile/features/documents/view/widgets/adaptive_documents_view.dart';
import 'package:paperless_mobile/features/documents/view/widgets/documents_empty_state.dart'; import 'package:paperless_mobile/features/documents/view/widgets/documents_empty_state.dart';
import 'package:paperless_mobile/features/documents/view/widgets/saved_views/saved_view_changed_dialog.dart';
import 'package:paperless_mobile/features/documents/view/widgets/saved_views/saved_views_widget.dart';
import 'package:paperless_mobile/features/documents/view/widgets/search/document_filter_panel.dart'; import 'package:paperless_mobile/features/documents/view/widgets/search/document_filter_panel.dart';
import 'package:paperless_mobile/features/documents/view/widgets/selection/confirm_delete_saved_view_dialog.dart';
import 'package:paperless_mobile/features/documents/view/widgets/selection/document_selection_sliver_app_bar.dart'; import 'package:paperless_mobile/features/documents/view/widgets/selection/document_selection_sliver_app_bar.dart';
import 'package:paperless_mobile/features/documents/view/widgets/selection/view_type_selection_widget.dart'; import 'package:paperless_mobile/features/documents/view/widgets/selection/view_type_selection_widget.dart';
import 'package:paperless_mobile/features/documents/view/widgets/sort_documents_button.dart'; import 'package:paperless_mobile/features/documents/view/widgets/sort_documents_button.dart';
import 'package:paperless_mobile/features/labels/cubit/label_cubit.dart';
import 'package:paperless_mobile/features/saved_view/cubit/saved_view_cubit.dart'; import 'package:paperless_mobile/features/saved_view/cubit/saved_view_cubit.dart';
import 'package:paperless_mobile/features/saved_view/view/saved_view_list.dart'; import 'package:paperless_mobile/features/tasks/model/pending_tasks_notifier.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/generated/l10n/app_localizations.dart';
import 'package:paperless_mobile/helpers/message_helpers.dart'; import 'package:paperless_mobile/helpers/message_helpers.dart';
import 'package:paperless_mobile/routes/typed/branches/documents_route.dart';
import 'package:sliver_tools/sliver_tools.dart';
class DocumentFilterIntent { class DocumentFilterIntent {
final DocumentFilter? filter; final DocumentFilter? filter;
@@ -41,283 +44,260 @@ class DocumentsPage extends StatefulWidget {
State<DocumentsPage> createState() => _DocumentsPageState(); State<DocumentsPage> createState() => _DocumentsPageState();
} }
class _DocumentsPageState extends State<DocumentsPage> class _DocumentsPageState extends State<DocumentsPage> {
with SingleTickerProviderStateMixin {
final SliverOverlapAbsorberHandle searchBarHandle = final SliverOverlapAbsorberHandle searchBarHandle =
SliverOverlapAbsorberHandle(); SliverOverlapAbsorberHandle();
final SliverOverlapAbsorberHandle tabBarHandle =
SliverOverlapAbsorberHandle();
late final TabController _tabController;
int _currentTab = 0; final SliverOverlapAbsorberHandle savedViewsHandle =
SliverOverlapAbsorberHandle();
final _nestedScrollViewKey = GlobalKey<NestedScrollViewState>();
final _savedViewsExpansionController = ExpansionTileController();
bool _showExtendedFab = true;
@override @override
void initState() { void initState() {
super.initState(); super.initState();
final showSavedViews = // context.read<PendingTasksNotifier>().addListener(_onTasksChanged);
LocalUserAccount.current.paperlessUser.canViewSavedViews; WidgetsBinding.instance.addPostFrameCallback((_) {
_tabController = TabController( _nestedScrollViewKey.currentState!.innerController
length: showSavedViews ? 2 : 1, .addListener(_scrollExtentChangedListener);
vsync: this, });
);
Future.wait([
context.read<DocumentsCubit>().reload(),
context.read<SavedViewCubit>().reload(),
]).onError<PaperlessApiException>(
(error, stackTrace) {
showErrorMessage(context, error, stackTrace);
return [];
},
);
_tabController.addListener(_tabChangesListener);
} }
void _tabChangesListener() { void _onTasksChanged() {
setState(() => _currentTab = _tabController.index); final notifier = context.read<PendingTasksNotifier>();
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<DocumentsCubit>().reload();
},
),
duration: const Duration(seconds: 10),
);
}
}
Future<void> _reloadData() async {
final user = context.read<LocalUserAccount>().paperlessUser;
try {
await Future.wait([
context.read<DocumentsCubit>().reload(),
if (user.canViewSavedViews) context.read<SavedViewCubit>().reload(),
if (user.canViewTags) context.read<LabelCubit>().reloadTags(),
if (user.canViewCorrespondents)
context.read<LabelCubit>().reloadCorrespondents(),
if (user.canViewDocumentTypes)
context.read<LabelCubit>().reloadDocumentTypes(),
if (user.canViewStoragePaths)
context.read<LabelCubit>().reloadStoragePaths(),
]);
} catch (error, stackTrace) {
showGenericError(context, error, stackTrace);
}
}
void _scrollExtentChangedListener() {
const threshold = 400;
final offset =
_nestedScrollViewKey.currentState!.innerController.position.pixels;
if (offset < threshold && _showExtendedFab == false) {
setState(() {
_showExtendedFab = true;
});
} else if (offset >= threshold && _showExtendedFab == true) {
setState(() {
_showExtendedFab = false;
});
}
} }
@override @override
void dispose() { void dispose() {
_tabController.dispose(); _nestedScrollViewKey.currentState?.innerController
.removeListener(_scrollExtentChangedListener);
// context.read<PendingTasksNotifier>().removeListener(_onTasksChanged);
super.dispose(); super.dispose();
} }
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return BlocListener<TaskStatusCubit, TaskStatusState>( return BlocConsumer<ConnectivityCubit, ConnectivityState>(
listenWhen: (previous, current) => listenWhen: (previous, current) =>
!previous.isSuccess && current.isSuccess, previous != ConnectivityState.connected &&
current == ConnectivityState.connected,
listener: (context, state) { listener: (context, state) {
showSnackBar( _reloadData();
context,
S.of(context)!.newDocumentAvailable,
action: SnackBarActionConfig(
label: S.of(context)!.reload,
onPressed: () {
context.read<TaskStatusCubit>().acknowledgeCurrentTask();
context.read<DocumentsCubit>().reload();
},
),
duration: const Duration(seconds: 10),
);
}, },
child: BlocConsumer<ConnectivityCubit, ConnectivityState>( builder: (context, connectivityState) {
listenWhen: (previous, current) => return SafeArea(
previous != ConnectivityState.connected && top: true,
current == ConnectivityState.connected, child: Scaffold(
listener: (context, state) { drawer: const AppDrawer(),
try { floatingActionButton: BlocBuilder<DocumentsCubit, DocumentsState>(
context.read<DocumentsCubit>().reload(); builder: (context, state) {
} on PaperlessApiException catch (error, stackTrace) { final show = state.selection.isEmpty;
showErrorMessage(context, error, stackTrace); final canReset = state.filter.appliedFiltersCount > 0;
} if (show) {
}, return Column(
builder: (context, connectivityState) { mainAxisAlignment: MainAxisAlignment.end,
return SafeArea( children: [
top: true, DeferredPointerHandler(
child: Scaffold( child: Stack(
drawer: const AppDrawer(), clipBehavior: Clip.none,
floatingActionButton: BlocBuilder<DocumentsCubit, DocumentsState>( children: [
builder: (context, state) { FloatingActionButton.extended(
final appliedFiltersCount = state.filter.appliedFiltersCount; extendedPadding: _showExtendedFab
final show = state.selection.isEmpty; ? null
final canReset = state.filter.appliedFiltersCount > 0; : const EdgeInsets.symmetric(horizontal: 16),
return AnimatedScale( heroTag: "fab_documents_page_filter",
scale: show ? 1 : 0, label: AnimatedSwitcher(
duration: const Duration(milliseconds: 200), duration: const Duration(milliseconds: 150),
curve: Curves.easeIn, transitionBuilder: (child, animation) {
child: Column( return FadeTransition(
mainAxisAlignment: MainAxisAlignment.end, opacity: animation,
children: [ child: SizeTransition(
if (canReset) sizeFactor: animation,
Padding( axis: Axis.horizontal,
padding: const EdgeInsets.all(8.0), child: child,
child: FloatingActionButton.small( ),
key: UniqueKey(), );
backgroundColor: Theme.of(context) },
.colorScheme child: _showExtendedFab
.onPrimaryContainer, ? Row(
onPressed: () { children: [
context.read<DocumentsCubit>().updateFilter(); const Icon(
}, Icons.filter_alt_outlined,
child: Icon( ),
Icons.refresh, const SizedBox(width: 8),
color: Theme.of(context) Text(
.colorScheme S.of(context)!.filterDocuments,
.primaryContainer, ),
],
)
: const Icon(Icons.filter_alt_outlined),
), ),
onPressed: _openDocumentFilter,
), ),
), if (canReset)
b.Badge( Positioned(
position: b.BadgePosition.topEnd(top: -12, end: -6), top: -20,
showBadge: appliedFiltersCount > 0, right: -8,
badgeContent: Text( child: DeferPointer(
'$appliedFiltersCount', paintOnTop: true,
style: const TextStyle( child: Material(
color: Colors.white, color: Theme.of(context).colorScheme.error,
), borderRadius: BorderRadius.circular(8),
), child: InkWell(
animationType: b.BadgeAnimationType.fade, borderRadius: BorderRadius.circular(8),
badgeColor: Colors.red, onTap: () {
child: AnimatedSwitcher( HapticFeedback.mediumImpact();
duration: const Duration(milliseconds: 250), _onResetFilter();
child: (_currentTab == 0) },
? FloatingActionButton( child: Row(
child: mainAxisSize: MainAxisSize.min,
const Icon(Icons.filter_alt_outlined), mainAxisAlignment:
onPressed: _openDocumentFilter, MainAxisAlignment.spaceBetween,
) children: [
: FloatingActionButton( if (_showExtendedFab)
child: const Icon(Icons.add), Text(
onPressed: () => "Reset (${state.filter.appliedFiltersCount})",
_onCreateSavedView(state.filter), 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),
],
),
),
), ),
),
),
],
),
);
},
),
resizeToAvoidBottomInset: true,
body: WillPopScope(
onWillPop: () async {
if (context
.read<DocumentsCubit>()
.state
.selection
.isNotEmpty) {
context.read<DocumentsCubit>().resetSelection();
return false;
}
return true;
},
child: NestedScrollView(
floatHeaderSlivers: true,
headerSliverBuilder: (context, innerBoxIsScrolled) => [
SliverOverlapAbsorber(
handle: searchBarHandle,
sliver: BlocBuilder<DocumentsCubit, DocumentsState>(
builder: (context, state) {
if (state.selection.isEmpty) {
return SliverSearchBar(
floating: true,
titleText: S.of(context)!.documents,
);
} else {
return DocumentSelectionSliverAppBar(
state: state,
);
}
},
),
),
SliverOverlapAbsorber(
handle: tabBarHandle,
sliver: BlocBuilder<DocumentsCubit, DocumentsState>(
builder: (context, state) {
if (state.selection.isNotEmpty) {
return const SliverToBoxAdapter(
child: SizedBox.shrink(),
);
}
return SliverPersistentHeader(
pinned: true,
delegate:
CustomizableSliverPersistentHeaderDelegate(
minExtent: kTextTabBarHeight,
maxExtent: kTextTabBarHeight,
child: ColoredTabBar(
tabBar: TabBar(
controller: _tabController,
tabs: [
Tab(text: S.of(context)!.documents),
if (LocalUserAccount.current.paperlessUser
.canViewSavedViews)
Tab(text: S.of(context)!.views),
],
), ),
), ),
), ],
);
},
),
),
],
body: NotificationListener<ScrollNotification>(
onNotification: (notification) {
final metrics = notification.metrics;
if (metrics.maxScrollExtent == 0) {
return true;
}
final desiredTab =
(metrics.pixels / metrics.maxScrollExtent).round();
if (metrics.axis == Axis.horizontal &&
_currentTab != desiredTab) {
setState(() => _currentTab = desiredTab);
}
return false;
},
child: TabBarView(
controller: _tabController,
physics: context
.watch<DocumentsCubit>()
.state
.selection
.isNotEmpty
? const NeverScrollableScrollPhysics()
: null,
children: [
Builder(
builder: (context) {
return _buildDocumentsTab(
connectivityState,
context,
);
},
), ),
if (LocalUserAccount ),
.current.paperlessUser.canViewSavedViews) ],
Builder( );
builder: (context) { } else {
return _buildSavedViewsTab( return const SizedBox.shrink();
connectivityState, }
context, },
); ),
}, resizeToAvoidBottomInset: true,
), body: WillPopScope(
], onWillPop: () async {
final cubit = context.read<DocumentsCubit>();
if (cubit.state.selection.isNotEmpty) {
cubit.resetSelection();
return false;
}
if (cubit.state.filter.appliedFiltersCount > 0 || cubit.state.filter.selectedView != null) {
await _onResetFilter();
return false;
}
return true;
},
child: NestedScrollView(
key: _nestedScrollViewKey,
floatHeaderSlivers: true,
headerSliverBuilder: (context, innerBoxIsScrolled) => [
SliverOverlapAbsorber(
handle: searchBarHandle,
sliver: BlocBuilder<DocumentsCubit, DocumentsState>(
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,
),
),
),
],
body: _buildDocumentsTab(
connectivityState,
context,
), ),
), ),
), ),
);
},
),
);
}
Widget _buildSavedViewsTab(
ConnectivityState connectivityState,
BuildContext context,
) {
return RefreshIndicator(
edgeOffset: kTextTabBarHeight,
onRefresh: _onReloadSavedViews,
notificationPredicate: (_) => connectivityState.isConnected,
child: CustomScrollView(
key: const PageStorageKey<String>("savedViews"),
slivers: <Widget>[
SliverOverlapInjector(
handle: searchBarHandle,
), ),
SliverOverlapInjector( );
handle: tabBarHandle, },
),
const SavedViewList(),
],
),
); );
} }
@@ -329,17 +309,19 @@ class _DocumentsPageState extends State<DocumentsPage>
onNotification: (notification) { onNotification: (notification) {
// Listen for scroll notifications to load new data. // Listen for scroll notifications to load new data.
// Scroll controller does not work here due to nestedscrollview limitations. // Scroll controller does not work here due to nestedscrollview limitations.
final offset = notification.metrics.pixels;
if (offset > 128 && _savedViewsExpansionController.isExpanded) {
_savedViewsExpansionController.collapse();
}
final currState = context.read<DocumentsCubit>().state;
final max = notification.metrics.maxScrollExtent; final max = notification.metrics.maxScrollExtent;
final currentState = context.read<DocumentsCubit>().state;
if (max == 0 || if (max == 0 ||
_currentTab != 0 || currentState.isLoading ||
currState.isLoading || currentState.isLastPageLoaded) {
currState.isLastPageLoaded) {
return false; return false;
} }
final offset = notification.metrics.pixels;
if (offset >= max * 0.7) { if (offset >= max * 0.7) {
context context
.read<DocumentsCubit>() .read<DocumentsCubit>()
@@ -356,29 +338,77 @@ class _DocumentsPageState extends State<DocumentsPage>
return false; return false;
}, },
child: RefreshIndicator( child: RefreshIndicator(
edgeOffset: kTextTabBarHeight, edgeOffset: kTextTabBarHeight + 2,
onRefresh: _onReloadDocuments, onRefresh: _reloadData,
notificationPredicate: (_) => connectivityState.isConnected, notificationPredicate: (_) => connectivityState.isConnected,
child: CustomScrollView( child: CustomScrollView(
key: const PageStorageKey<String>("documents"), key: const PageStorageKey<String>("documents"),
slivers: <Widget>[ slivers: <Widget>[
SliverOverlapInjector(handle: searchBarHandle), SliverOverlapInjector(handle: searchBarHandle),
SliverOverlapInjector(handle: tabBarHandle), SliverOverlapInjector(handle: savedViewsHandle),
_buildViewActions(), SliverToBoxAdapter(
child: BlocBuilder<DocumentsCubit, DocumentsState>(
buildWhen: (previous, current) =>
previous.filter != current.filter,
builder: (context, state) {
final currentUser = context.watch<LocalUserAccount>();
if (!currentUser.paperlessUser.canViewSavedViews) {
return const SizedBox.shrink();
}
return SavedViewsWidget(
controller: _savedViewsExpansionController,
onViewSelected: (view) {
final cubit = context.read<DocumentsCubit>();
if (state.filter.selectedView == view.id) {
_onResetFilter();
} else {
cubit.updateFilter(
filter: view.toDocumentFilter(),
);
}
},
onUpdateView: (view) async {
await context.read<SavedViewCubit>().update(view);
showSnackBar(
context, S.of(context)!.savedViewSuccessfullyUpdated);
},
onDeleteView: (view) async {
HapticFeedback.mediumImpact();
final shouldRemove = await showDialog(
context: context,
builder: (context) =>
ConfirmDeleteSavedViewDialog(view: view),
);
if (shouldRemove) {
final documentsCubit = context.read<DocumentsCubit>();
context.read<SavedViewCubit>().remove(view);
if (documentsCubit.state.filter.selectedView ==
view.id) {
documentsCubit.resetFilter();
}
}
},
filter: state.filter,
);
},
),
),
BlocBuilder<DocumentsCubit, DocumentsState>( BlocBuilder<DocumentsCubit, DocumentsState>(
builder: (context, state) { builder: (context, state) {
if (state.hasLoaded && state.documents.isEmpty) { if (state.hasLoaded && state.documents.isEmpty) {
return SliverToBoxAdapter( return SliverToBoxAdapter(
child: DocumentsEmptyState( child: DocumentsEmptyState(
state: state, state: state,
onReset: context.read<DocumentsCubit>().resetFilter, onReset: _onResetFilter,
), ),
); );
} }
final allowToggleFilter = state.selection.isEmpty; final allowToggleFilter = state.selection.isEmpty;
return SliverAdaptiveDocumentsView( return SliverAdaptiveDocumentsView(
viewType: state.viewType, viewType: state.viewType,
onTap: _openDetails, onTap: (document) {
DocumentDetailsRoute($extra: document).push(context);
},
onSelected: onSelected:
context.read<DocumentsCubit>().toggleDocumentSelection, context.read<DocumentsCubit>().toggleDocumentSelection,
hasInternetConnection: connectivityState.isConnected, hasInternetConnection: connectivityState.isConnected,
@@ -404,10 +434,12 @@ class _DocumentsPageState extends State<DocumentsPage>
} }
Widget _buildViewActions() { Widget _buildViewActions() {
return SliverToBoxAdapter( return BlocBuilder<DocumentsCubit, DocumentsState>(
child: BlocBuilder<DocumentsCubit, DocumentsState>( builder: (context, state) {
builder: (context, state) { return Container(
return Row( padding: const EdgeInsets.all(4),
color: Theme.of(context).colorScheme.background,
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween, mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [ children: [
SortDocumentsButton( SortDocumentsButton(
@@ -418,23 +450,12 @@ class _DocumentsPageState extends State<DocumentsPage>
onChanged: context.read<DocumentsCubit>().setViewType, onChanged: context.read<DocumentsCubit>().setViewType,
), ),
], ],
); ),
}, );
).paddedSymmetrically(horizontal: 8, vertical: 4), },
); );
} }
void _onCreateSavedView(DocumentFilter filter) async {
final newView = await pushAddSavedViewRoute(context, filter: filter);
if (newView != null) {
try {
await context.read<SavedViewCubit>().add(newView);
} on PaperlessApiException catch (error, stackTrace) {
showErrorMessage(context, error, stackTrace);
}
}
}
void _openDocumentFilter() async { void _openDocumentFilter() async {
final draggableSheetController = DraggableScrollableController(); final draggableSheetController = DraggableScrollableController();
final filterIntent = await showModalBottomSheet<DocumentFilterIntent>( final filterIntent = await showModalBottomSheet<DocumentFilterIntent>(
@@ -476,7 +497,7 @@ class _DocumentsPageState extends State<DocumentsPage>
if (filterIntent != null) { if (filterIntent != null) {
try { try {
if (filterIntent.shouldReset) { if (filterIntent.shouldReset) {
await context.read<DocumentsCubit>().resetFilter(); await _onResetFilter();
} else { } else {
await context await context
.read<DocumentsCubit>() .read<DocumentsCubit>()
@@ -488,13 +509,6 @@ class _DocumentsPageState extends State<DocumentsPage>
} }
} }
void _openDetails(DocumentModel document) {
pushDocumentDetailsRoute(
context,
document: document,
);
}
void _addTagToFilter(int tagId) { void _addTagToFilter(int tagId) {
final cubit = context.read<DocumentsCubit>(); final cubit = context.read<DocumentsCubit>();
try { try {
@@ -632,21 +646,46 @@ class _DocumentsPageState extends State<DocumentsPage>
} }
} }
Future<void> _onReloadDocuments() async { ///
try { /// Resets the current filter and scrolls all the way to the top of the view.
// We do not await here on purpose so we can show a linear progress indicator below the app bar. /// If a saved view is currently selected and the filter has changed,
await context.read<DocumentsCubit>().reload(); /// the user will be shown a dialog informing them about the changes.
} on PaperlessApiException catch (error, stackTrace) { /// The user can then decide whether to abort the reset or to continue and discard the changes.
showErrorMessage(context, error, stackTrace); Future<void> _onResetFilter() async {
} final cubit = context.read<DocumentsCubit>();
} final savedViewCubit = context.read<SavedViewCubit>();
Future<void> _onReloadSavedViews() async { void toTop() async {
try { await _nestedScrollViewKey.currentState?.outerController.animateTo(
// We do not await here on purpose so we can show a linear progress indicator below the app bar. 0,
await context.read<SavedViewCubit>().reload(); duration: const Duration(milliseconds: 300),
} on PaperlessApiException catch (error, stackTrace) { curve: Curves.easeOut,
showErrorMessage(context, error, stackTrace); );
}
final activeView = savedViewCubit.state.mapOrNull(
loaded: (state) {
if (cubit.state.filter.selectedView != null) {
return state.savedViews[cubit.state.filter.selectedView!];
}
return null;
},
);
final viewHasChanged = activeView != null &&
activeView.toDocumentFilter() != cubit.state.filter;
if (viewHasChanged) {
final discardChanges = await showDialog<bool>(
context: context,
builder: (context) => const SavedViewChangedDialog(),
) ??
false;
if (discardChanges) {
cubit.resetFilter();
toTop();
}
} else {
cubit.resetFilter();
toTop();
} }
} }
} }
@@ -2,6 +2,8 @@ import 'package:cached_network_image/cached_network_image.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter_cache_manager/flutter_cache_manager.dart'; import 'package:flutter_cache_manager/flutter_cache_manager.dart';
import 'package:paperless_api/paperless_api.dart'; import 'package:paperless_api/paperless_api.dart';
import 'package:paperless_mobile/helpers/connectivity_aware_action_wrapper.dart';
import 'package:paperless_mobile/routes/typed/branches/documents_route.dart';
import 'package:provider/provider.dart'; import 'package:provider/provider.dart';
import 'package:shimmer/shimmer.dart'; import 'package:shimmer/shimmer.dart';
@@ -12,6 +14,7 @@ class DocumentPreview extends StatelessWidget {
final double borderRadius; final double borderRadius;
final bool enableHero; final bool enableHero;
final double scale; final double scale;
final bool isClickable;
const DocumentPreview({ const DocumentPreview({
super.key, super.key,
@@ -21,15 +24,26 @@ class DocumentPreview extends StatelessWidget {
this.borderRadius = 12.0, this.borderRadius = 12.0,
this.enableHero = true, this.enableHero = true,
this.scale = 1.1, this.scale = 1.1,
this.isClickable = true,
}); });
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return HeroMode( return ConnectivityAwareActionWrapper(
enabled: enableHero, child: GestureDetector(
child: Hero( behavior: HitTestBehavior.translucent,
tag: "thumb_${document.id}", onTap: isClickable
child: _buildPreview(context), ? () => DocumentPreviewRoute($extra: document).push(context)
: null,
child: Builder(builder: (context) {
if (enableHero) {
return Hero(
tag: "thumb_${document.id}",
child: _buildPreview(context),
);
}
return _buildPreview(context);
}),
), ),
); );
} }
@@ -1,6 +1,6 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:paperless_api/paperless_api.dart'; import 'package:paperless_api/paperless_api.dart';
import 'package:paperless_mobile/core/widgets/empty_state.dart';
import 'package:paperless_mobile/extensions/flutter_extensions.dart'; import 'package:paperless_mobile/extensions/flutter_extensions.dart';
import 'package:paperless_mobile/features/paged_document_view/cubit/paged_documents_state.dart'; import 'package:paperless_mobile/features/paged_document_view/cubit/paged_documents_state.dart';
import 'package:paperless_mobile/generated/l10n/app_localizations.dart'; import 'package:paperless_mobile/generated/l10n/app_localizations.dart';
@@ -8,6 +8,7 @@ import 'package:paperless_mobile/generated/l10n/app_localizations.dart';
class DocumentsEmptyState extends StatelessWidget { class DocumentsEmptyState extends StatelessWidget {
final DocumentPagingState state; final DocumentPagingState state;
final VoidCallback? onReset; final VoidCallback? onReset;
const DocumentsEmptyState({ const DocumentsEmptyState({
Key? key, Key? key,
required this.state, required this.state,
@@ -17,18 +18,24 @@ class DocumentsEmptyState extends StatelessWidget {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return Center( return Center(
child: EmptyState( child: Column(
title: S.of(context)!.oops, children: [
subtitle: S.of(context)!.thereSeemsToBeNothingHere, Text(
bottomChild: state.filter != DocumentFilter.initial && onReset != null S.of(context)!.noDocumentsFound,
? TextButton( style: Theme.of(context).textTheme.titleSmall,
onPressed: onReset, ),
child: Text( if (state.filter != DocumentFilter.initial && onReset != null)
S.of(context)!.resetFilter, TextButton(
), onPressed: () {
).padded() HapticFeedback.mediumImpact();
: null, onReset!();
), },
child: Text(
S.of(context)!.resetFilter,
),
).padded(),
],
).padded(24),
); );
} }
} }
@@ -2,7 +2,12 @@ import 'dart:math';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter_html/flutter_html.dart'; import 'package:flutter_html/flutter_html.dart';
import 'package:hive_flutter/adapters.dart';
import 'package:intl/intl.dart'; import 'package:intl/intl.dart';
import 'package:paperless_api/paperless_api.dart';
import 'package:paperless_mobile/core/config/hive/hive_config.dart';
import 'package:paperless_mobile/core/database/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/repository/label_repository.dart';
import 'package:paperless_mobile/extensions/flutter_extensions.dart'; import 'package:paperless_mobile/extensions/flutter_extensions.dart';
import 'package:paperless_mobile/features/documents/view/widgets/document_preview.dart'; import 'package:paperless_mobile/features/documents/view/widgets/document_preview.dart';
@@ -32,6 +37,12 @@ class DocumentDetailedItem extends DocumentItem {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final currentUserId = Hive.box<GlobalSettings>(HiveBoxes.globalSettings)
.getValue()!
.loggedInUserId;
final paperlessUser = Hive.box<LocalUserAccount>(HiveBoxes.localUserAccount)
.get(currentUserId)!
.paperlessUser;
final size = MediaQuery.of(context).size; final size = MediaQuery.of(context).size;
final insets = MediaQuery.of(context).viewInsets; final insets = MediaQuery.of(context).viewInsets;
final padding = MediaQuery.of(context).viewPadding; final padding = MediaQuery.of(context).viewPadding;
@@ -104,48 +115,51 @@ class DocumentDetailedItem extends DocumentItem {
maxLines: 2, maxLines: 2,
overflow: TextOverflow.ellipsis, overflow: TextOverflow.ellipsis,
).paddedLTRB(8, 0, 8, 4), ).paddedLTRB(8, 0, 8, 4),
Row( if (paperlessUser.canViewCorrespondents)
children: [ Row(
const Icon( children: [
Icons.person_outline, const Icon(
size: 16, Icons.person_outline,
).paddedOnly(right: 4.0), size: 16,
CorrespondentWidget( ).paddedOnly(right: 4.0),
onSelected: onCorrespondentSelected, CorrespondentWidget(
textStyle: Theme.of(context).textTheme.titleSmall?.apply( onSelected: onCorrespondentSelected,
color: Theme.of(context).colorScheme.onSurfaceVariant, textStyle: Theme.of(context).textTheme.titleSmall?.apply(
), color: Theme.of(context).colorScheme.onSurfaceVariant,
correspondent: context ),
.watch<LabelRepository>() correspondent: context
.state .watch<LabelRepository>()
.correspondents[document.correspondent], .state
), .correspondents[document.correspondent],
], ),
).paddedLTRB(8, 0, 8, 4), ],
Row( ).paddedLTRB(8, 0, 8, 4),
children: [ if (paperlessUser.canViewDocumentTypes)
const Icon( Row(
Icons.description_outlined, children: [
size: 16, const Icon(
).paddedOnly(right: 4.0), Icons.description_outlined,
DocumentTypeWidget( size: 16,
onSelected: onDocumentTypeSelected, ).paddedOnly(right: 4.0),
textStyle: Theme.of(context).textTheme.titleSmall?.apply( DocumentTypeWidget(
color: Theme.of(context).colorScheme.onSurfaceVariant, onSelected: onDocumentTypeSelected,
), textStyle: Theme.of(context).textTheme.titleSmall?.apply(
documentType: context color: Theme.of(context).colorScheme.onSurfaceVariant,
.watch<LabelRepository>() ),
.state documentType: context
.documentTypes[document.documentType], .watch<LabelRepository>()
), .state
], .documentTypes[document.documentType],
).paddedLTRB(8, 0, 8, 4), ),
TagsWidget( ],
tags: document.tags ).paddedLTRB(8, 0, 8, 4),
.map((e) => context.watch<LabelRepository>().state.tags[e]!) if (paperlessUser.canViewTags)
.toList(), TagsWidget(
onTagSelected: onTagSelected, tags: document.tags
).padded(), .map((e) => context.watch<LabelRepository>().state.tags[e]!)
.toList(),
onTagSelected: onTagSelected,
).padded(),
if (highlights != null) if (highlights != null)
Html( Html(
data: '<p>${highlights!}</p>', data: '<p>${highlights!}</p>',
@@ -1,5 +1,7 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:intl/intl.dart'; import 'package:intl/intl.dart';
import 'package:paperless_api/paperless_api.dart';
import 'package:paperless_mobile/core/database/tables/local_user_account.dart';
import 'package:paperless_mobile/core/repository/label_repository.dart'; import 'package:paperless_mobile/core/repository/label_repository.dart';
import 'package:paperless_mobile/features/documents/view/widgets/document_preview.dart'; import 'package:paperless_mobile/features/documents/view/widgets/document_preview.dart';
import 'package:paperless_mobile/features/documents/view/widgets/items/document_item.dart'; import 'package:paperless_mobile/features/documents/view/widgets/items/document_item.dart';
@@ -26,6 +28,7 @@ class DocumentGridItem extends DocumentItem {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
var currentUser = context.watch<LocalUserAccount>().paperlessUser;
return Padding( return Padding(
padding: const EdgeInsets.all(8.0), padding: const EdgeInsets.all(8.0),
child: Card( child: Card(
@@ -64,15 +67,16 @@ class DocumentGridItem extends DocumentItem {
const SliverToBoxAdapter( const SliverToBoxAdapter(
child: SizedBox(width: 8), child: SizedBox(width: 8),
), ),
TagsWidget.sliver( if (currentUser.canViewTags)
tags: document.tags TagsWidget.sliver(
.map((e) => context tags: document.tags
.watch<LabelRepository>() .map((e) => context
.state .watch<LabelRepository>()
.tags[e]!) .state
.toList(), .tags[e]!)
onTagSelected: onTagSelected, .toList(),
), onTagSelected: onTagSelected,
),
const SliverToBoxAdapter( const SliverToBoxAdapter(
child: SizedBox(width: 8), child: SizedBox(width: 8),
), ),
@@ -90,20 +94,22 @@ class DocumentGridItem extends DocumentItem {
child: Column( child: Column(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
CorrespondentWidget( if (currentUser.canViewCorrespondents)
correspondent: context CorrespondentWidget(
.watch<LabelRepository>() correspondent: context
.state .watch<LabelRepository>()
.correspondents[document.correspondent], .state
onSelected: onCorrespondentSelected, .correspondents[document.correspondent],
), onSelected: onCorrespondentSelected,
DocumentTypeWidget( ),
documentType: context if (currentUser.canViewDocumentTypes)
.watch<LabelRepository>() DocumentTypeWidget(
.state documentType: context
.documentTypes[document.documentType], .watch<LabelRepository>()
onSelected: onDocumentTypeSelected, .state
), .documentTypes[document.documentType],
onSelected: onDocumentTypeSelected,
),
Padding( Padding(
padding: const EdgeInsets.only(bottom: 8.0), padding: const EdgeInsets.only(bottom: 8.0),
child: Text( child: Text(
@@ -11,8 +11,10 @@ import 'package:provider/provider.dart';
class DocumentListItem extends DocumentItem { class DocumentListItem extends DocumentItem {
static const _a4AspectRatio = 1 / 1.4142; static const _a4AspectRatio = 1 / 1.4142;
final Color? backgroundColor;
const DocumentListItem({ const DocumentListItem({
super.key, super.key,
this.backgroundColor,
required super.document, required super.document,
required super.isSelected, required super.isSelected,
required super.isSelectionActive, required super.isSelectionActive,
@@ -29,91 +31,90 @@ class DocumentListItem extends DocumentItem {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final labels = context.watch<LabelRepository>().state; final labels = context.watch<LabelRepository>().state;
return Material( return ListTile(
child: ListTile( tileColor: backgroundColor,
dense: true, dense: true,
selected: isSelected, selected: isSelected,
onTap: () => _onTap(), onTap: () => _onTap(),
selectedTileColor: Theme.of(context).colorScheme.inversePrimary, selectedTileColor: Theme.of(context).colorScheme.inversePrimary,
onLongPress: () => onSelected?.call(document), onLongPress: onSelected != null ? () => onSelected!(document) : null,
title: Column( title: Column(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
mainAxisAlignment: MainAxisAlignment.spaceAround, mainAxisAlignment: MainAxisAlignment.spaceAround,
children: [ children: [
Row( Row(
children: [ children: [
AbsorbPointer( AbsorbPointer(
absorbing: isSelectionActive, absorbing: isSelectionActive,
child: CorrespondentWidget( child: CorrespondentWidget(
isClickable: isLabelClickable, isClickable: isLabelClickable,
correspondent: context correspondent: context
.watch<LabelRepository>() .watch<LabelRepository>()
.state .state
.correspondents[document.correspondent], .correspondents[document.correspondent],
onSelected: onCorrespondentSelected, onSelected: onCorrespondentSelected,
),
), ),
],
),
Text(
document.title,
overflow: TextOverflow.ellipsis,
maxLines: 1,
),
AbsorbPointer(
absorbing: isSelectionActive,
child: TagsWidget(
isClickable: isLabelClickable,
tags: document.tags
.where((e) => labels.tags.containsKey(e))
.map((e) => labels.tags[e]!)
.toList(),
onTagSelected: (id) => onTagSelected?.call(id),
), ),
), ],
], ),
), Text(
subtitle: Padding( document.title,
padding: const EdgeInsets.symmetric(vertical: 4),
child: RichText(
maxLines: 1,
overflow: TextOverflow.ellipsis, overflow: TextOverflow.ellipsis,
text: TextSpan( maxLines: 1,
text: DateFormat.yMMMd().format(document.created), ),
style: Theme.of(context) AbsorbPointer(
.textTheme absorbing: isSelectionActive,
.labelSmall child: TagsWidget(
?.apply(color: Colors.grey), isClickable: isLabelClickable,
children: document.documentType != null tags: document.tags
? [ .where((e) => labels.tags.containsKey(e))
const TextSpan(text: '\u30FB'), .map((e) => labels.tags[e]!)
TextSpan( .toList(),
text: labels.documentTypes[document.documentType]?.name, onTagSelected: (id) => onTagSelected?.call(id),
recognizer: onDocumentTypeSelected != null
? (TapGestureRecognizer()
..onTap = () => onDocumentTypeSelected!(
document.documentType))
: null,
),
]
: null,
), ),
), ),
), ],
isThreeLine: document.tags.isNotEmpty,
leading: AspectRatio(
aspectRatio: _a4AspectRatio,
child: GestureDetector(
child: DocumentPreview(
document: document,
fit: BoxFit.cover,
alignment: Alignment.topCenter,
enableHero: enableHeroAnimation,
),
),
),
contentPadding: const EdgeInsets.all(8.0),
), ),
subtitle: Padding(
padding: const EdgeInsets.symmetric(vertical: 4),
child: RichText(
maxLines: 1,
overflow: TextOverflow.ellipsis,
text: TextSpan(
text: DateFormat.yMMMd().format(document.created),
style: Theme.of(context)
.textTheme
.labelSmall
?.apply(color: Colors.grey),
children: document.documentType != null
? [
const TextSpan(text: '\u30FB'),
TextSpan(
text: labels.documentTypes[document.documentType]?.name,
recognizer: onDocumentTypeSelected != null
? (TapGestureRecognizer()
..onTap = () =>
onDocumentTypeSelected!(document.documentType))
: null,
),
]
: null,
),
),
),
isThreeLine: document.tags.isNotEmpty,
leading: AspectRatio(
aspectRatio: _a4AspectRatio,
child: GestureDetector(
child: DocumentPreview(
document: document,
fit: BoxFit.cover,
alignment: Alignment.topCenter,
enableHero: enableHeroAnimation,
),
),
),
contentPadding: const EdgeInsets.all(8.0),
); );
} }
@@ -1,11 +0,0 @@
import 'package:flutter/material.dart';
import 'package:paperless_mobile/extensions/flutter_extensions.dart';
class NewItemsLoadingWidget extends StatelessWidget {
const NewItemsLoadingWidget({super.key});
@override
Widget build(BuildContext context) {
return Center(child: const CircularProgressIndicator().padded());
}
}
@@ -0,0 +1,25 @@
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/generated/l10n/app_localizations.dart';
class SavedViewChangedDialog extends StatelessWidget {
const SavedViewChangedDialog({super.key});
@override
Widget build(BuildContext context) {
return AlertDialog(
title: Text(S.of(context)!.discardChanges),
content: Text(S.of(context)!.savedViewChangedDialogContent),
actionsOverflowButtonSpacing: 8,
actions: [
const DialogCancelButton(),
DialogConfirmButton(
label: S.of(context)!.resetFilter,
style: DialogConfirmButtonStyle.danger,
returnValue: true,
),
],
);
}
}
@@ -0,0 +1,165 @@
import 'dart:math';
import 'package:flutter/material.dart';
import 'package:paperless_api/paperless_api.dart';
import 'package:paperless_mobile/extensions/flutter_extensions.dart';
import 'package:paperless_mobile/routes/typed/branches/saved_views_route.dart';
class SavedViewChip extends StatefulWidget {
final SavedView view;
final void Function(SavedView view) onViewSelected;
final void Function(SavedView view) onUpdateView;
final void Function(SavedView view) onDeleteView;
final bool selected;
final bool hasChanged;
const SavedViewChip({
super.key,
required this.view,
required this.onViewSelected,
required this.selected,
required this.hasChanged,
required this.onUpdateView,
required this.onDeleteView,
});
@override
State<SavedViewChip> createState() => _SavedViewChipState();
}
class _SavedViewChipState extends State<SavedViewChip>
with SingleTickerProviderStateMixin {
late AnimationController _animationController;
late Animation<double> _animation;
@override
void initState() {
super.initState();
_animationController = AnimationController(
vsync: this,
duration: const Duration(milliseconds: 200),
);
_animation = _animationController.drive(Tween(begin: 0, end: 1));
}
bool _isExpanded = false;
@override
Widget build(BuildContext context) {
var colorScheme = Theme.of(context).colorScheme;
final effectiveBackgroundColor = widget.selected
? colorScheme.secondaryContainer
: colorScheme.surfaceVariant;
final effectiveForegroundColor = widget.selected
? colorScheme.onSecondaryContainer
: colorScheme.onSurfaceVariant;
final expandedChild = Row(
children: [
IconButton(
padding: EdgeInsets.zero,
icon: Icon(
Icons.edit,
color: effectiveForegroundColor,
),
onPressed: () {
EditSavedViewRoute(widget.view).push(context);
},
),
IconButton(
padding: EdgeInsets.zero,
icon: Icon(
Icons.delete,
color: colorScheme.error,
),
onPressed: () async {
widget.onDeleteView(widget.view);
},
),
],
);
return Material(
color: effectiveBackgroundColor,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(8),
side: BorderSide(
color: colorScheme.outline,
),
),
child: InkWell(
enableFeedback: true,
borderRadius: BorderRadius.circular(8),
onTap: () => widget.onViewSelected(widget.view),
child: Padding(
padding: const EdgeInsets.only(right: 4),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Row(
children: [
_buildCheckmark(effectiveForegroundColor),
_buildLabel(context, effectiveForegroundColor)
.paddedSymmetrically(
horizontal: 12,
),
],
).paddedOnly(left: 8),
AnimatedSwitcher(
duration: const Duration(milliseconds: 350),
child: _isExpanded ? expandedChild : const SizedBox.shrink(),
),
_buildTrailing(effectiveForegroundColor),
],
),
),
),
);
}
Widget _buildTrailing(Color effectiveForegroundColor) {
return IconButton(
padding: EdgeInsets.zero,
icon: AnimatedBuilder(
animation: _animation,
builder: (context, child) {
return Transform.rotate(
angle: _animation.value * pi,
child: Icon(
_isExpanded ? Icons.close : Icons.chevron_right,
color: effectiveForegroundColor,
),
);
},
),
onPressed: () {
if (_isExpanded) {
_animationController.reverse();
} else {
_animationController.forward();
}
setState(() {
_isExpanded = !_isExpanded;
});
},
);
}
Widget _buildLabel(BuildContext context, Color effectiveForegroundColor) {
return Text(
widget.view.name,
style: Theme.of(context)
.textTheme
.labelLarge
?.copyWith(color: effectiveForegroundColor),
);
}
Widget _buildCheckmark(Color effectiveForegroundColor) {
return AnimatedSize(
duration: const Duration(milliseconds: 300),
child: widget.selected
? Icon(Icons.check, color: effectiveForegroundColor)
: const SizedBox.shrink(),
);
}
}
@@ -0,0 +1,236 @@
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:paperless_api/paperless_api.dart';
import 'package:paperless_mobile/core/widgets/shimmer_placeholder.dart';
import 'package:paperless_mobile/extensions/flutter_extensions.dart';
import 'package:paperless_mobile/features/documents/view/widgets/saved_views/saved_view_chip.dart';
import 'package:paperless_mobile/features/saved_view/cubit/saved_view_cubit.dart';
import 'package:paperless_mobile/generated/l10n/app_localizations.dart';
import 'package:paperless_mobile/helpers/connectivity_aware_action_wrapper.dart';
import 'package:paperless_mobile/routes/typed/branches/saved_views_route.dart';
class SavedViewsWidget extends StatefulWidget {
final void Function(SavedView view) onViewSelected;
final void Function(SavedView view) onUpdateView;
final void Function(SavedView view) onDeleteView;
final DocumentFilter filter;
final ExpansionTileController? controller;
const SavedViewsWidget({
super.key,
required this.onViewSelected,
required this.filter,
required this.onUpdateView,
required this.onDeleteView,
this.controller,
});
@override
State<SavedViewsWidget> createState() => _SavedViewsWidgetState();
}
class _SavedViewsWidgetState extends State<SavedViewsWidget>
with SingleTickerProviderStateMixin {
late final AnimationController _animationController;
late final Animation<double> _animation;
@override
void initState() {
super.initState();
_animationController = AnimationController(
vsync: this,
duration: const Duration(milliseconds: 200),
);
_animation = _animationController.drive(Tween(begin: 0, end: 0.5));
}
@override
Widget build(BuildContext context) {
return BlocBuilder<SavedViewCubit, SavedViewState>(
builder: (context, state) {
final selectedView = state.mapOrNull(
loaded: (value) {
if (widget.filter.selectedView != null) {
return value.savedViews[widget.filter.selectedView!];
}
},
);
final selectedViewHasChanged = selectedView != null &&
selectedView.toDocumentFilter() != widget.filter;
return PageStorage(
bucket: PageStorageBucket(),
child: ExpansionTile(
controller: widget.controller,
tilePadding: const EdgeInsets.only(left: 8),
trailing: RotationTransition(
turns: _animation,
child: const Icon(Icons.expand_more),
).paddedOnly(right: 8),
onExpansionChanged: (isExpanded) {
if (isExpanded) {
_animationController.forward();
} else {
_animationController.reverse().then((value) => setState(() {}));
}
},
title: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Flexible(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
S.of(context)!.views,
style: Theme.of(context).textTheme.labelLarge,
),
if (selectedView != null)
Text(
selectedView.name,
style:
Theme.of(context).textTheme.labelMedium?.copyWith(
color: Theme.of(context)
.colorScheme
.onBackground
.withOpacity(0.5),
),
maxLines: 2,
overflow: TextOverflow.ellipsis,
),
],
),
),
AnimatedScale(
scale: selectedViewHasChanged ? 1 : 0,
duration: const Duration(milliseconds: 150),
child: TextButton(
onPressed: () {
final newView = selectedView!.copyWith(
filterRules: FilterRule.fromFilter(widget.filter),
);
widget.onUpdateView(newView);
},
child: Text(S.of(context)!.saveChanges),
),
)
],
),
leading: Icon(
Icons.saved_search,
color: Theme.of(context).colorScheme.primary,
).padded(),
expandedCrossAxisAlignment: CrossAxisAlignment.start,
children: [
state
.maybeMap(
loaded: (value) {
if (value.savedViews.isEmpty) {
return Text(S.of(context)!.youDidNotSaveAnyViewsYet)
.paddedOnly(left: 16);
}
return SizedBox(
height: kMinInteractiveDimension,
child: NotificationListener<ScrollNotification>(
onNotification: (notification) => true,
child: CustomScrollView(
scrollDirection: Axis.horizontal,
slivers: [
const SliverToBoxAdapter(
child: SizedBox(width: 12),
),
SliverList.separated(
itemBuilder: (context, index) {
final view =
value.savedViews.values.elementAt(index);
final isSelected =
(widget.filter.selectedView ?? -1) ==
view.id;
return ConnectivityAwareActionWrapper(
child: SavedViewChip(
view: view,
onViewSelected: widget.onViewSelected,
selected: isSelected,
hasChanged: isSelected &&
view.toDocumentFilter() !=
widget.filter,
onUpdateView: widget.onUpdateView,
onDeleteView: widget.onDeleteView,
),
);
},
separatorBuilder: (context, index) =>
const SizedBox(width: 8),
itemCount: value.savedViews.length,
),
const SliverToBoxAdapter(
child: SizedBox(width: 12),
),
],
),
),
);
},
error: (_) => Text(S.of(context)!.couldNotLoadSavedViews)
.paddedOnly(left: 16),
orElse: _buildLoadingState,
)
.paddedOnly(top: 16),
Align(
alignment: Alignment.centerRight,
child: Tooltip(
message: S.of(context)!.createFromCurrentFilter,
child: ConnectivityAwareActionWrapper(
child: TextButton.icon(
onPressed: () {
CreateSavedViewRoute(widget.filter).push(context);
},
icon: const Icon(Icons.add),
label: Text(S.of(context)!.newView),
),
),
).padded(4),
),
],
),
);
},
);
}
Widget _buildLoadingState() {
return Container(
margin: const EdgeInsets.only(top: 16),
height: kMinInteractiveDimension,
child: NotificationListener<ScrollNotification>(
onNotification: (notification) => true,
child: ShimmerPlaceholder(
child: CustomScrollView(
scrollDirection: Axis.horizontal,
slivers: [
const SliverToBoxAdapter(
child: SizedBox(width: 12),
),
SliverList.separated(
itemBuilder: (context, index) {
return Container(
width: 130,
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(8),
color: Colors.white,
),
);
},
separatorBuilder: (context, index) => const SizedBox(width: 8),
),
const SliverToBoxAdapter(
child: SizedBox(width: 12),
),
],
),
),
),
);
}
}
@@ -1,4 +1,5 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:flutter_form_builder/flutter_form_builder.dart'; import 'package:flutter_form_builder/flutter_form_builder.dart';
import 'package:paperless_api/paperless_api.dart'; import 'package:paperless_api/paperless_api.dart';
import 'package:paperless_mobile/core/database/tables/local_user_account.dart'; import 'package:paperless_mobile/core/database/tables/local_user_account.dart';
@@ -18,10 +19,12 @@ class DocumentFilterForm extends StatefulWidget {
static const fkAddedAt = DocumentModel.addedKey; static const fkAddedAt = DocumentModel.addedKey;
static DocumentFilter assembleFilter( static DocumentFilter assembleFilter(
GlobalKey<FormBuilderState> formKey, DocumentFilter initialFilter) { GlobalKey<FormBuilderState> formKey,
DocumentFilter initialFilter,
) {
formKey.currentState?.save(); formKey.currentState?.save();
final v = formKey.currentState!.value; final v = formKey.currentState!.value;
return DocumentFilter( return initialFilter.copyWith(
correspondent: correspondent:
v[DocumentFilterForm.fkCorrespondent] as IdQueryParameter? ?? v[DocumentFilterForm.fkCorrespondent] as IdQueryParameter? ??
DocumentFilter.initial.correspondent, DocumentFilter.initial.correspondent,
@@ -35,11 +38,7 @@ class DocumentFilterForm extends StatefulWidget {
DocumentFilter.initial.query, DocumentFilter.initial.query,
created: (v[DocumentFilterForm.fkCreatedAt] as DateRangeQuery), created: (v[DocumentFilterForm.fkCreatedAt] as DateRangeQuery),
added: (v[DocumentFilterForm.fkAddedAt] as DateRangeQuery), added: (v[DocumentFilterForm.fkAddedAt] as DateRangeQuery),
asnQuery: initialFilter.asnQuery,
page: 1, page: 1,
pageSize: initialFilter.pageSize,
sortField: initialFilter.sortField,
sortOrder: initialFilter.sortOrder,
); );
} }
@@ -160,8 +159,10 @@ class _DocumentFilterFormState extends State<DocumentFilterForm> {
initialValue: widget.initialFilter.documentType, initialValue: widget.initialFilter.documentType,
prefixIcon: const Icon(Icons.description_outlined), prefixIcon: const Icon(Icons.description_outlined),
allowSelectUnassigned: false, allowSelectUnassigned: false,
canCreateNewLabel: canCreateNewLabel: context
LocalUserAccount.current.paperlessUser.canCreateDocumentTypes, .watch<LocalUserAccount>()
.paperlessUser
.canCreateDocumentTypes,
); );
} }
@@ -173,8 +174,10 @@ class _DocumentFilterFormState extends State<DocumentFilterForm> {
initialValue: widget.initialFilter.correspondent, initialValue: widget.initialFilter.correspondent,
prefixIcon: const Icon(Icons.person_outline), prefixIcon: const Icon(Icons.person_outline),
allowSelectUnassigned: false, allowSelectUnassigned: false,
canCreateNewLabel: canCreateNewLabel: context
LocalUserAccount.current.paperlessUser.canCreateCorrespondents, .watch<LocalUserAccount>()
.paperlessUser
.canCreateCorrespondents,
); );
} }
@@ -187,7 +190,7 @@ class _DocumentFilterFormState extends State<DocumentFilterForm> {
prefixIcon: const Icon(Icons.folder_outlined), prefixIcon: const Icon(Icons.folder_outlined),
allowSelectUnassigned: false, allowSelectUnassigned: false,
canCreateNewLabel: canCreateNewLabel:
LocalUserAccount.current.paperlessUser.canCreateStoragePaths, context.watch<LocalUserAccount>().paperlessUser.canCreateStoragePaths,
); );
} }
@@ -80,6 +80,7 @@ class _DocumentFilterPanelState extends State<DocumentFilterPanel> {
floatingActionButton: Visibility( floatingActionButton: Visibility(
visible: MediaQuery.of(context).viewInsets.bottom == 0, visible: MediaQuery.of(context).viewInsets.bottom == 0,
child: FloatingActionButton.extended( child: FloatingActionButton.extended(
heroTag: "fab_document_filter_panel",
icon: const Icon(Icons.done), icon: const Icon(Icons.done),
label: Text(S.of(context)!.apply), label: Text(S.of(context)!.apply),
onPressed: _onApplyFilter, onPressed: _onApplyFilter,
@@ -16,7 +16,7 @@ class ConfirmDeleteSavedViewDialog extends StatelessWidget {
Widget build(BuildContext context) { Widget build(BuildContext context) {
return AlertDialog( return AlertDialog(
title: Text( title: Text(
S.of(context)!.deleteView + view.name + "?", S.of(context)!.deleteView(view.name),
softWrap: true, softWrap: true,
), ),
content: Text(S.of(context)!.doYouReallyWantToDeleteThisView), content: Text(S.of(context)!.doYouReallyWantToDeleteThisView),
@@ -1,12 +1,12 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:paperless_api/paperless_api.dart'; import 'package:paperless_api/paperless_api.dart';
import 'package:paperless_mobile/core/navigation/push_routes.dart';
import 'package:paperless_mobile/extensions/flutter_extensions.dart'; import 'package:paperless_mobile/extensions/flutter_extensions.dart';
import 'package:paperless_mobile/features/documents/cubit/documents_cubit.dart'; import 'package:paperless_mobile/features/documents/cubit/documents_cubit.dart';
import 'package:paperless_mobile/features/documents/view/widgets/selection/bulk_delete_confirmation_dialog.dart'; import 'package:paperless_mobile/features/documents/view/widgets/selection/bulk_delete_confirmation_dialog.dart';
import 'package:paperless_mobile/generated/l10n/app_localizations.dart'; import 'package:paperless_mobile/generated/l10n/app_localizations.dart';
import 'package:paperless_mobile/helpers/message_helpers.dart'; import 'package:paperless_mobile/helpers/message_helpers.dart';
import 'package:paperless_mobile/routes/typed/branches/documents_route.dart';
class DocumentSelectionSliverAppBar extends StatelessWidget { class DocumentSelectionSliverAppBar extends StatelessWidget {
final DocumentsState state; final DocumentsState state;
@@ -65,24 +65,30 @@ class DocumentSelectionSliverAppBar extends StatelessWidget {
label: Text(S.of(context)!.correspondent), label: Text(S.of(context)!.correspondent),
avatar: const Icon(Icons.edit), avatar: const Icon(Icons.edit),
onPressed: () { onPressed: () {
pushBulkEditCorrespondentRoute(context, BulkEditDocumentsRoute(BulkEditExtraWrapper(
selection: state.selection); state.selection,
LabelType.correspondent,
)).push(context);
}, },
).paddedOnly(left: 8, right: 4), ).paddedOnly(left: 8, right: 4),
ActionChip( ActionChip(
label: Text(S.of(context)!.documentType), label: Text(S.of(context)!.documentType),
avatar: const Icon(Icons.edit), avatar: const Icon(Icons.edit),
onPressed: () async { onPressed: () async {
pushBulkEditDocumentTypeRoute(context, BulkEditDocumentsRoute(BulkEditExtraWrapper(
selection: state.selection); state.selection,
LabelType.documentType,
)).push(context);
}, },
).paddedOnly(left: 8, right: 4), ).paddedOnly(left: 8, right: 4),
ActionChip( ActionChip(
label: Text(S.of(context)!.storagePath), label: Text(S.of(context)!.storagePath),
avatar: const Icon(Icons.edit), avatar: const Icon(Icons.edit),
onPressed: () async { onPressed: () async {
pushBulkEditStoragePathRoute(context, BulkEditDocumentsRoute(BulkEditExtraWrapper(
selection: state.selection); state.selection,
LabelType.storagePath,
)).push(context);
}, },
).paddedOnly(left: 8, right: 4), ).paddedOnly(left: 8, right: 4),
_buildBulkEditTagsChip(context).paddedOnly(left: 4, right: 4), _buildBulkEditTagsChip(context).paddedOnly(left: 4, right: 4),
@@ -98,7 +104,10 @@ class DocumentSelectionSliverAppBar extends StatelessWidget {
label: Text(S.of(context)!.tags), label: Text(S.of(context)!.tags),
avatar: const Icon(Icons.edit), avatar: const Icon(Icons.edit),
onPressed: () { onPressed: () {
pushBulkEditTagsRoute(context, selection: state.selection); BulkEditDocumentsRoute(BulkEditExtraWrapper(
state.selection,
LabelType.tag,
)).push(context);
}, },
); );
} }
@@ -5,6 +5,7 @@ import 'package:paperless_mobile/core/translation/sort_field_localization_mapper
import 'package:paperless_mobile/features/documents/cubit/documents_cubit.dart'; import 'package:paperless_mobile/features/documents/cubit/documents_cubit.dart';
import 'package:paperless_mobile/features/documents/view/widgets/search/sort_field_selection_bottom_sheet.dart'; import 'package:paperless_mobile/features/documents/view/widgets/search/sort_field_selection_bottom_sheet.dart';
import 'package:paperless_mobile/features/labels/cubit/label_cubit.dart'; import 'package:paperless_mobile/features/labels/cubit/label_cubit.dart';
import 'package:paperless_mobile/helpers/connectivity_aware_action_wrapper.dart';
class SortDocumentsButton extends StatelessWidget { class SortDocumentsButton extends StatelessWidget {
final bool enabled; final bool enabled;
@@ -20,55 +21,65 @@ class SortDocumentsButton extends StatelessWidget {
if (state.filter.sortField == null) { if (state.filter.sortField == null) {
return const SizedBox.shrink(); return const SizedBox.shrink();
} }
print(state.filter.sortField); final icon = Icon(state.filter.sortOrder == SortOrder.ascending
return TextButton.icon( ? Icons.arrow_upward
icon: Icon(state.filter.sortOrder == SortOrder.ascending : Icons.arrow_downward);
? Icons.arrow_upward final label = Text(translateSortField(context, state.filter.sortField));
: Icons.arrow_downward), return ConnectivityAwareActionWrapper(
label: Text(translateSortField(context, state.filter.sortField)), offlineBuilder: (context, child) {
onPressed: enabled return TextButton.icon(
? () { icon: icon,
showModalBottomSheet( label: label,
elevation: 2, onPressed: null,
context: context, );
isScrollControlled: true, },
shape: const RoundedRectangleBorder( child: TextButton.icon(
borderRadius: BorderRadius.only( icon: icon,
topLeft: Radius.circular(16), label: label,
topRight: Radius.circular(16), onPressed: enabled
), ? () {
), showModalBottomSheet(
builder: (_) => BlocProvider<DocumentsCubit>.value( elevation: 2,
value: context.read<DocumentsCubit>(), context: context,
child: MultiBlocProvider( isScrollControlled: true,
providers: [ shape: const RoundedRectangleBorder(
BlocProvider( borderRadius: BorderRadius.only(
create: (context) => LabelCubit(context.read()), topLeft: Radius.circular(16),
), topRight: Radius.circular(16),
],
child: SortFieldSelectionBottomSheet(
initialSortField: state.filter.sortField,
initialSortOrder: state.filter.sortOrder,
onSubmit: (field, order) {
return context
.read<DocumentsCubit>()
.updateCurrentFilter(
(filter) => filter.copyWith(
sortField: field,
sortOrder: order,
),
);
},
correspondents: state.correspondents,
documentTypes: state.documentTypes,
storagePaths: state.storagePaths,
tags: state.tags,
), ),
), ),
), builder: (_) => BlocProvider<DocumentsCubit>.value(
); value: context.read<DocumentsCubit>(),
} child: MultiBlocProvider(
: null, providers: [
BlocProvider(
create: (context) => LabelCubit(context.read()),
),
],
child: SortFieldSelectionBottomSheet(
initialSortField: state.filter.sortField,
initialSortOrder: state.filter.sortOrder,
onSubmit: (field, order) {
return context
.read<DocumentsCubit>()
.updateCurrentFilter(
(filter) => filter.copyWith(
sortField: field,
sortOrder: order,
),
);
},
correspondents: state.correspondents,
documentTypes: state.documentTypes,
storagePaths: state.storagePaths,
tags: state.tags,
),
),
),
);
}
: null,
),
); );
}, },
); );
@@ -2,14 +2,16 @@ import 'dart:developer';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:flutter_form_builder/flutter_form_builder.dart';
import 'package:go_router/go_router.dart';
import 'package:paperless_api/paperless_api.dart'; import 'package:paperless_api/paperless_api.dart';
import 'package:paperless_mobile/core/repository/label_repository.dart'; import 'package:paperless_mobile/core/repository/label_repository.dart';
import 'package:paperless_mobile/core/widgets/dialog_utils/dialog_cancel_button.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/dialog_utils/dialog_confirm_button.dart';
import 'package:paperless_mobile/core/widgets/dialog_utils/pop_with_unsaved_changes.dart';
import 'package:paperless_mobile/features/edit_label/cubit/edit_label_cubit.dart'; import 'package:paperless_mobile/features/edit_label/cubit/edit_label_cubit.dart';
import 'package:paperless_mobile/features/edit_label/view/label_form.dart'; import 'package:paperless_mobile/features/edit_label/view/label_form.dart';
import 'package:paperless_mobile/generated/l10n/app_localizations.dart'; import 'package:paperless_mobile/generated/l10n/app_localizations.dart';
import 'package:paperless_mobile/helpers/message_helpers.dart'; import 'package:paperless_mobile/helpers/message_helpers.dart';
class EditLabelPage<T extends Label> extends StatelessWidget { class EditLabelPage<T extends Label> extends StatelessWidget {
@@ -55,8 +57,9 @@ class EditLabelForm<T extends Label> extends StatelessWidget {
final Future<T> Function(BuildContext context, T label) onSubmit; final Future<T> Function(BuildContext context, T label) onSubmit;
final Future<void> Function(BuildContext context, T label) onDelete; final Future<void> Function(BuildContext context, T label) onDelete;
final bool canDelete; final bool canDelete;
final _formKey = GlobalKey<FormBuilderState>();
const EditLabelForm({ EditLabelForm({
super.key, super.key,
required this.label, required this.label,
required this.fromJsonT, required this.fromJsonT,
@@ -68,26 +71,32 @@ class EditLabelForm<T extends Label> extends StatelessWidget {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return Scaffold( return PopWithUnsavedChanges(
appBar: AppBar( hasChangesPredicate: () {
title: Text(S.of(context)!.edit), return _formKey.currentState?.isDirty ?? false;
actions: [ },
IconButton( child: Scaffold(
onPressed: canDelete ? () => _onDelete(context) : null, appBar: AppBar(
icon: const Icon(Icons.delete), title: Text(S.of(context)!.edit),
), actions: [
], IconButton(
), onPressed: canDelete ? () => _onDelete(context) : null,
body: LabelForm<T>( icon: const Icon(Icons.delete),
autofocusNameField: false, ),
initialValue: label, ],
fromJsonT: fromJsonT, ),
submitButtonConfig: SubmitButtonConfig<T>( body: LabelForm<T>(
icon: const Icon(Icons.save), formKey: _formKey,
label: Text(S.of(context)!.saveChanges), autofocusNameField: false,
onSubmit: (label) => onSubmit(context, label), initialValue: label,
fromJsonT: fromJsonT,
submitButtonConfig: SubmitButtonConfig<T>(
icon: const Icon(Icons.save),
label: Text(S.of(context)!.saveChanges),
onSubmit: (label) => onSubmit(context, label),
),
additionalFields: additionalFields,
), ),
additionalFields: additionalFields,
), ),
); );
} }
@@ -119,11 +128,11 @@ class EditLabelForm<T extends Label> extends StatelessWidget {
} catch (error, stackTrace) { } catch (error, stackTrace) {
log("An error occurred!", error: error, stackTrace: stackTrace); log("An error occurred!", error: error, stackTrace: stackTrace);
} }
Navigator.pop(context); context.pop();
} }
} else { } else {
onDelete(context, label); onDelete(context, label);
Navigator.pop(context); context.pop();
} }
} }
} }
@@ -7,8 +7,8 @@ import 'package:paperless_mobile/features/labels/storage_path/view/widgets/stora
import 'package:paperless_mobile/generated/l10n/app_localizations.dart'; import 'package:paperless_mobile/generated/l10n/app_localizations.dart';
class AddStoragePathPage extends StatelessWidget { class AddStoragePathPage extends StatelessWidget {
final String? initalName; final String? initialName;
const AddStoragePathPage({Key? key, this.initalName}) : super(key: key); const AddStoragePathPage({Key? key, this.initialName}) : super(key: key);
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
@@ -19,7 +19,7 @@ class AddStoragePathPage extends StatelessWidget {
child: AddLabelPage<StoragePath>( child: AddLabelPage<StoragePath>(
pageTitle: Text(S.of(context)!.addStoragePath), pageTitle: Text(S.of(context)!.addStoragePath),
fromJsonT: StoragePath.fromJson, fromJsonT: StoragePath.fromJson,
initialName: initalName, initialName: initialName,
onSubmit: (context, label) => onSubmit: (context, label) =>
context.read<EditLabelCubit>().addStoragePath(label), context.read<EditLabelCubit>().addStoragePath(label),
additionalFields: const [ additionalFields: const [
@@ -10,8 +10,8 @@ import 'package:paperless_mobile/features/edit_label/view/add_label_page.dart';
import 'package:paperless_mobile/generated/l10n/app_localizations.dart'; import 'package:paperless_mobile/generated/l10n/app_localizations.dart';
class AddTagPage extends StatelessWidget { class AddTagPage extends StatelessWidget {
final String? initialValue; final String? initialName;
const AddTagPage({Key? key, this.initialValue}) : super(key: key); const AddTagPage({Key? key, this.initialName}) : super(key: key);
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
@@ -22,7 +22,7 @@ class AddTagPage extends StatelessWidget {
child: AddLabelPage<Tag>( child: AddLabelPage<Tag>(
pageTitle: Text(S.of(context)!.addTag), pageTitle: Text(S.of(context)!.addTag),
fromJsonT: Tag.fromJson, fromJsonT: Tag.fromJson,
initialName: initialValue, initialName: initialName,
onSubmit: (context, label) => onSubmit: (context, label) =>
context.read<EditLabelCubit>().addTag(label), context.read<EditLabelCubit>().addTag(label),
additionalFields: [ additionalFields: [
@@ -37,9 +37,16 @@ class AddTagPage extends StatelessWidget {
.withOpacity(1.0), .withOpacity(1.0),
readOnly: true, readOnly: true,
), ),
FormBuilderCheckbox( FormBuilderField<bool>(
name: Tag.isInboxTagKey, name: Tag.isInboxTagKey,
title: Text(S.of(context)!.inboxTag), initialValue: false,
builder: (field) {
return CheckboxListTile(
value: field.value,
title: Text(S.of(context)!.inboxTag),
onChanged: (value) => field.didChange(value),
);
},
), ),
], ],
), ),
@@ -24,8 +24,10 @@ class EditCorrespondentPage extends StatelessWidget {
context.read<EditLabelCubit>().replaceCorrespondent(label), context.read<EditLabelCubit>().replaceCorrespondent(label),
onDelete: (context, label) => onDelete: (context, label) =>
context.read<EditLabelCubit>().removeCorrespondent(label), context.read<EditLabelCubit>().removeCorrespondent(label),
canDelete: canDelete: context
LocalUserAccount.current.paperlessUser.canDeleteCorrespondents, .watch<LocalUserAccount>()
.paperlessUser
.canDeleteCorrespondents,
); );
}), }),
); );
@@ -22,8 +22,10 @@ class EditDocumentTypePage extends StatelessWidget {
context.read<EditLabelCubit>().replaceDocumentType(label), context.read<EditLabelCubit>().replaceDocumentType(label),
onDelete: (context, label) => onDelete: (context, label) =>
context.read<EditLabelCubit>().removeDocumentType(label), context.read<EditLabelCubit>().removeDocumentType(label),
canDelete: canDelete: context
LocalUserAccount.current.paperlessUser.canDeleteDocumentTypes, .watch<LocalUserAccount>()
.paperlessUser
.canDeleteDocumentTypes,
), ),
); );
} }
@@ -23,7 +23,10 @@ class EditStoragePathPage extends StatelessWidget {
context.read<EditLabelCubit>().replaceStoragePath(label), context.read<EditLabelCubit>().replaceStoragePath(label),
onDelete: (context, label) => onDelete: (context, label) =>
context.read<EditLabelCubit>().removeStoragePath(label), context.read<EditLabelCubit>().removeStoragePath(label),
canDelete: LocalUserAccount.current.paperlessUser.canDeleteStoragePaths, canDelete: context
.watch<LocalUserAccount>()
.paperlessUser
.canDeleteStoragePaths,
additionalFields: [ additionalFields: [
StoragePathAutofillFormBuilderField( StoragePathAutofillFormBuilderField(
name: StoragePath.pathKey, name: StoragePath.pathKey,
@@ -26,7 +26,8 @@ class EditTagPage extends StatelessWidget {
context.read<EditLabelCubit>().replaceTag(label), context.read<EditLabelCubit>().replaceTag(label),
onDelete: (context, label) => onDelete: (context, label) =>
context.read<EditLabelCubit>().removeTag(label), context.read<EditLabelCubit>().removeTag(label),
canDelete: LocalUserAccount.current.paperlessUser.canDeleteTags, canDelete:
context.watch<LocalUserAccount>().paperlessUser.canDeleteTags,
additionalFields: [ additionalFields: [
FormBuilderColorPickerField( FormBuilderColorPickerField(
initialValue: tag.color, initialValue: tag.color,
@@ -37,10 +38,16 @@ class EditTagPage extends StatelessWidget {
colorPickerType: ColorPickerType.materialPicker, colorPickerType: ColorPickerType.materialPicker,
readOnly: true, readOnly: true,
), ),
FormBuilderCheckbox( FormBuilderField<bool>(
initialValue: tag.isInboxTag,
name: Tag.isInboxTagKey, name: Tag.isInboxTagKey,
title: Text(S.of(context)!.inboxTag), initialValue: tag.isInboxTag,
builder: (field) {
return CheckboxListTile(
value: field.value,
title: Text(S.of(context)!.inboxTag),
onChanged: (value) => field.didChange(value),
);
},
), ),
], ],
), ),
+17 -8
View File
@@ -1,13 +1,12 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:flutter_form_builder/flutter_form_builder.dart'; import 'package:flutter_form_builder/flutter_form_builder.dart';
import 'package:go_router/go_router.dart';
import 'package:paperless_api/paperless_api.dart'; import 'package:paperless_api/paperless_api.dart';
import 'package:paperless_mobile/core/database/tables/local_user_account.dart';
import 'package:paperless_mobile/core/translation/matching_algorithm_localization_mapper.dart'; import 'package:paperless_mobile/core/translation/matching_algorithm_localization_mapper.dart';
import 'package:paperless_mobile/extensions/flutter_extensions.dart'; import 'package:paperless_mobile/extensions/flutter_extensions.dart';
import 'package:paperless_mobile/features/home/view/model/api_version.dart';
import 'package:paperless_mobile/generated/l10n/app_localizations.dart'; import 'package:paperless_mobile/generated/l10n/app_localizations.dart';
import 'package:paperless_mobile/helpers/message_helpers.dart'; import 'package:paperless_mobile/helpers/message_helpers.dart';
class SubmitButtonConfig<T extends Label> { class SubmitButtonConfig<T extends Label> {
@@ -34,6 +33,7 @@ class LabelForm<T extends Label> extends StatefulWidget {
final List<Widget> additionalFields; final List<Widget> additionalFields;
final bool autofocusNameField; final bool autofocusNameField;
final GlobalKey<FormBuilderState>? formKey;
const LabelForm({ const LabelForm({
Key? key, Key? key,
@@ -42,6 +42,7 @@ class LabelForm<T extends Label> extends StatefulWidget {
this.additionalFields = const [], this.additionalFields = const [],
required this.submitButtonConfig, required this.submitButtonConfig,
required this.autofocusNameField, required this.autofocusNameField,
this.formKey,
}) : super(key: key); }) : super(key: key);
@override @override
@@ -49,7 +50,7 @@ class LabelForm<T extends Label> extends StatefulWidget {
} }
class _LabelFormState<T extends Label> extends State<LabelForm<T>> { class _LabelFormState<T extends Label> extends State<LabelForm<T>> {
final _formKey = GlobalKey<FormBuilderState>(); late final GlobalKey<FormBuilderState> _formKey;
late bool _enableMatchFormField; late bool _enableMatchFormField;
@@ -58,6 +59,7 @@ class _LabelFormState<T extends Label> extends State<LabelForm<T>> {
@override @override
void initState() { void initState() {
super.initState(); super.initState();
_formKey = widget.formKey ?? GlobalKey<FormBuilderState>();
var matchingAlgorithm = (widget.initialValue?.matchingAlgorithm ?? var matchingAlgorithm = (widget.initialValue?.matchingAlgorithm ??
MatchingAlgorithm.defaultValue); MatchingAlgorithm.defaultValue);
_enableMatchFormField = matchingAlgorithm != MatchingAlgorithm.auto && _enableMatchFormField = matchingAlgorithm != MatchingAlgorithm.auto &&
@@ -68,11 +70,12 @@ class _LabelFormState<T extends Label> extends State<LabelForm<T>> {
Widget build(BuildContext context) { Widget build(BuildContext context) {
List<MatchingAlgorithm> selectableMatchingAlgorithmValues = List<MatchingAlgorithm> selectableMatchingAlgorithmValues =
getSelectableMatchingAlgorithmValues( getSelectableMatchingAlgorithmValues(
context.watch<ApiVersion>().hasMultiUserSupport, context.watch<LocalUserAccount>().hasMultiUserSupport,
); );
return Scaffold( return Scaffold(
resizeToAvoidBottomInset: false, resizeToAvoidBottomInset: false,
floatingActionButton: FloatingActionButton.extended( floatingActionButton: FloatingActionButton.extended(
heroTag: "fab_label_form",
icon: widget.submitButtonConfig.icon, icon: widget.submitButtonConfig.icon,
label: widget.submitButtonConfig.label, label: widget.submitButtonConfig.label,
onPressed: _onSubmit, onPressed: _onSubmit,
@@ -134,10 +137,16 @@ class _LabelFormState<T extends Label> extends State<LabelForm<T>> {
initialValue: widget.initialValue?.match, initialValue: widget.initialValue?.match,
onChanged: (val) => setState(() => _errors = {}), onChanged: (val) => setState(() => _errors = {}),
), ),
FormBuilderCheckbox( FormBuilderField<bool>(
name: Label.isInsensitiveKey, name: Label.isInsensitiveKey,
initialValue: widget.initialValue?.isInsensitive ?? true, initialValue: widget.initialValue?.isInsensitive ?? true,
title: Text(S.of(context)!.caseIrrelevant), builder: (field) {
return CheckboxListTile(
value: field.value,
title: Text(S.of(context)!.caseIrrelevant),
onChanged: (value) => field.didChange(value),
);
},
), ),
...widget.additionalFields, ...widget.additionalFields,
].padded(), ].padded(),
@@ -167,7 +176,7 @@ class _LabelFormState<T extends Label> extends State<LabelForm<T>> {
}; };
final parsed = widget.fromJsonT(mergedJson); final parsed = widget.fromJsonT(mergedJson);
final createdLabel = await widget.submitButtonConfig.onSubmit(parsed); final createdLabel = await widget.submitButtonConfig.onSubmit(parsed);
Navigator.pop(context, createdLabel); context.pop(createdLabel);
} on PaperlessApiException catch (error, stackTrace) { } on PaperlessApiException catch (error, stackTrace) {
showErrorMessage(context, error, stackTrace); showErrorMessage(context, error, stackTrace);
} on PaperlessFormValidationException catch (exception) { } on PaperlessFormValidationException catch (exception) {
-330
View File
@@ -1,330 +0,0 @@
import 'dart:async';
import 'dart:developer';
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/bloc/connectivity_cubit.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/global/constants.dart';
import 'package:paperless_mobile/core/navigation/push_routes.dart';
import 'package:paperless_mobile/core/repository/label_repository.dart';
import 'package:paperless_mobile/core/repository/saved_view_repository.dart';
import 'package:paperless_mobile/core/service/file_description.dart';
import 'package:paperless_mobile/core/translation/error_code_localization_mapper.dart';
import 'package:paperless_mobile/features/document_scan/view/scanner_page.dart';
import 'package:paperless_mobile/features/documents/view/pages/documents_page.dart';
import 'package:paperless_mobile/features/home/view/route_description.dart';
import 'package:paperless_mobile/features/inbox/cubit/inbox_cubit.dart';
import 'package:paperless_mobile/features/inbox/view/pages/inbox_page.dart';
import 'package:paperless_mobile/features/labels/view/pages/labels_page.dart';
import 'package:paperless_mobile/features/notifications/services/local_notification_service.dart';
import 'package:paperless_mobile/features/sharing/share_intent_queue.dart';
import 'package:paperless_mobile/features/tasks/cubit/task_status_cubit.dart';
import 'package:paperless_mobile/generated/l10n/app_localizations.dart';
import 'package:receive_sharing_intent/receive_sharing_intent.dart';
import 'package:responsive_builder/responsive_builder.dart';
/// Wrapper around all functionality for a logged in user.
/// Performs initialization logic.
class HomePage extends StatefulWidget {
final int paperlessApiVersion;
const HomePage({Key? key, required this.paperlessApiVersion})
: super(key: key);
@override
_HomePageState createState() => _HomePageState();
}
class _HomePageState extends State<HomePage> with WidgetsBindingObserver {
int _currentIndex = 0;
Timer? _inboxTimer;
late final StreamSubscription _shareMediaSubscription;
@override
void initState() {
super.initState();
WidgetsBinding.instance.addObserver(this);
final currentUser = Hive.box<GlobalSettings>(HiveBoxes.globalSettings)
.getValue()!
.currentLoggedInUser!;
// For sharing files coming from outside the app while the app is still opened
_shareMediaSubscription = ReceiveSharingIntent.getMediaStream().listen(
(files) =>
ShareIntentQueue.instance.addAll(files, userId: currentUser));
// For sharing files coming from outside the app while the app is closed
ReceiveSharingIntent.getInitialMedia().then((files) =>
ShareIntentQueue.instance.addAll(files, userId: currentUser));
WidgetsBinding.instance.addPostFrameCallback((timeStamp) {
_listenForReceivedFiles();
});
}
@override
void didChangeDependencies() {
super.didChangeDependencies();
}
void _listenToInboxChanges() {
if (LocalUserAccount.current.paperlessUser.canViewTags) {
_inboxTimer = Timer.periodic(const Duration(seconds: 60), (timer) {
if (!mounted) {
timer.cancel();
} else {
context.read<InboxCubit>().refreshItemsInInboxCount();
}
});
}
}
@override
void didChangeAppLifecycleState(AppLifecycleState state) {
switch (state) {
case AppLifecycleState.resumed:
log('App is now in foreground');
context.read<ConnectivityCubit>().reload();
log("Reloaded device connectivity state");
if (!(_inboxTimer?.isActive ?? true)) {
_listenToInboxChanges();
}
break;
case AppLifecycleState.inactive:
case AppLifecycleState.paused:
case AppLifecycleState.detached:
default:
log('App is now in background');
_inboxTimer?.cancel();
break;
}
}
@override
void dispose() {
WidgetsBinding.instance.removeObserver(this);
_inboxTimer?.cancel();
_shareMediaSubscription.cancel();
super.dispose();
}
void _listenForReceivedFiles() async {
final currentUser = Hive.box<GlobalSettings>(HiveBoxes.globalSettings)
.getValue()!
.currentLoggedInUser!;
if (ShareIntentQueue.instance.userHasUnhandlesFiles(currentUser)) {
await _handleReceivedFile(ShareIntentQueue.instance.pop(currentUser)!);
}
ShareIntentQueue.instance.addListener(() async {
final queue = ShareIntentQueue.instance;
while (queue.userHasUnhandlesFiles(currentUser)) {
final file = queue.pop(currentUser)!;
await _handleReceivedFile(file);
}
});
}
bool _isFileTypeSupported(SharedMediaFile file) {
return supportedFileExtensions.contains(
file.path.split('.').last.toLowerCase(),
);
}
Future<void> _handleReceivedFile(final SharedMediaFile file) async {
SharedMediaFile mediaFile;
if (Platform.isIOS) {
// Workaround for file not found on iOS: https://stackoverflow.com/a/72813212
mediaFile = SharedMediaFile(
file.path.replaceAll('file://', ''),
file.thumbnail,
file.duration,
file.type,
);
} else {
mediaFile = file;
}
debugPrint("Consuming media file: ${mediaFile.path}");
if (!_isFileTypeSupported(mediaFile)) {
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 (!LocalUserAccount.current.paperlessUser.canCreateDocuments) {
Fluttertoast.showToast(
msg: "You do not have the permissions to upload documents.",
);
return;
}
final fileDescription = FileDescription.fromPath(mediaFile.path);
if (await File(mediaFile.path).exists()) {
final bytes = await File(mediaFile.path).readAsBytes();
final result = await pushDocumentUploadPreparationPage(
context,
bytes: bytes,
filename: fileDescription.filename,
title: fileDescription.filename,
fileExtension: fileDescription.extension,
);
if (result?.success ?? false) {
await Fluttertoast.showToast(
msg: S.of(context)!.documentSuccessfullyUploadedProcessing,
);
SystemNavigator.pop();
}
} else {
Fluttertoast.showToast(
msg: S.of(context)!.couldNotAccessReceivedFile,
toastLength: Toast.LENGTH_LONG,
);
}
}
@override
Widget build(BuildContext context) {
final destinations = [
RouteDescription(
icon: const Icon(Icons.description_outlined),
selectedIcon: Icon(
Icons.description,
color: Theme.of(context).colorScheme.primary,
),
label: S.of(context)!.documents,
),
if (LocalUserAccount.current.paperlessUser.canCreateDocuments)
RouteDescription(
icon: const Icon(Icons.document_scanner_outlined),
selectedIcon: Icon(
Icons.document_scanner,
color: Theme.of(context).colorScheme.primary,
),
label: S.of(context)!.scanner,
),
RouteDescription(
icon: const Icon(Icons.sell_outlined),
selectedIcon: Icon(
Icons.sell,
color: Theme.of(context).colorScheme.primary,
),
label: S.of(context)!.labels,
),
if (LocalUserAccount.current.paperlessUser.canViewTags)
RouteDescription(
icon: const Icon(Icons.inbox_outlined),
selectedIcon: Icon(
Icons.inbox,
color: Theme.of(context).colorScheme.primary,
),
label: S.of(context)!.inbox,
badgeBuilder: (icon) => BlocBuilder<InboxCubit, InboxState>(
builder: (context, state) {
return Badge.count(
isLabelVisible: state.itemsInInboxCount > 0,
count: state.itemsInInboxCount,
child: icon,
);
},
),
),
];
final routes = <Widget>[
const DocumentsPage(),
if (LocalUserAccount.current.paperlessUser.canCreateDocuments)
const ScannerPage(),
const LabelsPage(),
if (LocalUserAccount.current.paperlessUser.canViewTags) const InboxPage(),
];
return MultiBlocListener(
listeners: [
BlocListener<ConnectivityCubit, ConnectivityState>(
// If app was started offline, load data once it comes back online.
listenWhen: (previous, current) =>
previous != ConnectivityState.connected &&
current == ConnectivityState.connected,
listener: (context, state) async {
try {
debugPrint(
"[HomePage] BlocListener#listener: "
"Loading saved views and labels...",
);
await Future.wait([
context.read<LabelRepository>().initialize(),
context.read<SavedViewRepository>().initialize(),
]);
debugPrint("[HomePage] BlocListener#listener: "
"Saved views and labels successfully loaded.");
} catch (error, stackTrace) {
debugPrint(
'[HomePage] BlocListener.listener: '
'An error occurred while loading saved views and labels.\n'
'${error.toString()}',
);
debugPrintStack(stackTrace: stackTrace);
}
},
),
BlocListener<TaskStatusCubit, TaskStatusState>(
listener: (context, state) {
if (state.task != null) {
// Handle local notifications on task change (only when app is running for now).
context
.read<LocalNotificationService>()
.notifyTaskChanged(state.task!);
}
},
),
],
child: ResponsiveBuilder(
builder: (context, sizingInformation) {
if (!sizingInformation.isMobile) {
return Scaffold(
body: Row(
children: [
NavigationRail(
labelType: NavigationRailLabelType.all,
destinations: destinations
.map((e) => e.toNavigationRailDestination())
.toList(),
selectedIndex: _currentIndex,
onDestinationSelected: _onNavigationChanged,
),
const VerticalDivider(thickness: 1, width: 1),
Expanded(
child: routes[_currentIndex],
),
],
),
);
}
return Scaffold(
bottomNavigationBar: NavigationBar(
labelBehavior: NavigationDestinationLabelBehavior.alwaysShow,
elevation: 4.0,
selectedIndex: _currentIndex,
onDestinationSelected: _onNavigationChanged,
destinations:
destinations.map((e) => e.toNavigationDestination()).toList(),
),
body: routes[_currentIndex],
);
},
),
);
}
void _onNavigationChanged(index) {
if (_currentIndex != index) {
setState(() => _currentIndex = index);
}
}
}
-204
View File
@@ -1,204 +0,0 @@
import 'package:flutter/material.dart';
import 'package:flutter_cache_manager/flutter_cache_manager.dart';
import 'package:hive_flutter/adapters.dart';
import 'package:paperless_api/paperless_api.dart';
import 'package:paperless_mobile/core/config/hive/hive_config.dart';
import 'package:paperless_mobile/core/database/tables/local_user_account.dart';
import 'package:paperless_mobile/core/database/tables/local_user_app_state.dart';
import 'package:paperless_mobile/core/factory/paperless_api_factory.dart';
import 'package:paperless_mobile/core/notifier/document_changed_notifier.dart';
import 'package:paperless_mobile/core/repository/label_repository.dart';
import 'package:paperless_mobile/core/repository/saved_view_repository.dart';
import 'package:paperless_mobile/core/repository/user_repository.dart';
import 'package:paperless_mobile/core/security/session_manager.dart';
import 'package:paperless_mobile/core/service/dio_file_service.dart';
import 'package:paperless_mobile/features/document_scan/cubit/document_scanner_cubit.dart';
import 'package:paperless_mobile/features/documents/cubit/documents_cubit.dart';
import 'package:paperless_mobile/features/home/view/home_page.dart';
import 'package:paperless_mobile/features/home/view/model/api_version.dart';
import 'package:paperless_mobile/features/inbox/cubit/inbox_cubit.dart';
import 'package:paperless_mobile/features/labels/cubit/label_cubit.dart';
import 'package:paperless_mobile/features/saved_view/cubit/saved_view_cubit.dart';
import 'package:paperless_mobile/features/settings/view/widgets/global_settings_builder.dart';
import 'package:paperless_mobile/features/tasks/cubit/task_status_cubit.dart';
import 'package:provider/provider.dart';
class HomeRoute extends StatelessWidget {
/// The id of the currently authenticated user (e.g. demo@paperless.example.com)
final String localUserId;
/// The Paperless API version of the currently connected instance
final int paperlessApiVersion;
// A factory providing the API implementations given an API version
final PaperlessApiFactory paperlessProviderFactory;
const HomeRoute({
super.key,
required this.paperlessApiVersion,
required this.paperlessProviderFactory,
required this.localUserId,
});
@override
Widget build(BuildContext context) {
return GlobalSettingsBuilder(
builder: (context, settings) {
final currentLocalUserId = settings.currentLoggedInUser;
if (currentLocalUserId == null) {
// This is the case when the current user logs out of the app.
return SizedBox.shrink();
}
final currentUser =
Hive.box<LocalUserAccount>(HiveBoxes.localUserAccount)
.get(currentLocalUserId)!;
final apiVersion = ApiVersion(paperlessApiVersion);
return MultiProvider(
providers: [
Provider.value(value: apiVersion),
Provider<CacheManager>(
create: (context) => CacheManager(
Config(
// Isolated cache per user.
localUserId,
fileService:
DioFileService(context.read<SessionManager>().client),
),
),
),
ProxyProvider<SessionManager, PaperlessDocumentsApi>(
update: (context, value, previous) =>
paperlessProviderFactory.createDocumentsApi(
value.client,
apiVersion: paperlessApiVersion,
),
),
ProxyProvider<SessionManager, PaperlessLabelsApi>(
update: (context, value, previous) =>
paperlessProviderFactory.createLabelsApi(
value.client,
apiVersion: paperlessApiVersion,
),
),
ProxyProvider<SessionManager, PaperlessSavedViewsApi>(
update: (context, value, previous) =>
paperlessProviderFactory.createSavedViewsApi(
value.client,
apiVersion: paperlessApiVersion,
),
),
ProxyProvider<SessionManager, PaperlessServerStatsApi>(
update: (context, value, previous) =>
paperlessProviderFactory.createServerStatsApi(
value.client,
apiVersion: paperlessApiVersion,
),
),
ProxyProvider<SessionManager, PaperlessTasksApi>(
update: (context, value, previous) =>
paperlessProviderFactory.createTasksApi(
value.client,
apiVersion: paperlessApiVersion,
),
),
if (apiVersion.hasMultiUserSupport)
ProxyProvider<SessionManager, PaperlessUserApiV3>(
update: (context, value, previous) => PaperlessUserApiV3Impl(
value.client,
),
),
],
builder: (context, child) {
return MultiProvider(
providers: [
ProxyProvider<PaperlessLabelsApi, LabelRepository>(
update: (context, value, previous) {
final repo = LabelRepository(value);
if (currentUser.paperlessUser.canViewCorrespondents) {
repo.findAllCorrespondents();
}
if (currentUser.paperlessUser.canViewDocumentTypes) {
repo.findAllDocumentTypes();
}
if (currentUser.paperlessUser.canViewTags) {
repo.findAllTags();
}
if (currentUser.paperlessUser.canViewStoragePaths) {
repo.findAllStoragePaths();
}
return repo;
},
),
ProxyProvider<PaperlessSavedViewsApi, SavedViewRepository>(
update: (context, value, previous) {
final repo = SavedViewRepository(value);
if (currentUser.paperlessUser.canViewSavedViews) {
repo.initialize();
}
return repo;
},
),
],
builder: (context, child) {
return MultiProvider(
providers: [
ProxyProvider3<
PaperlessDocumentsApi,
DocumentChangedNotifier,
LabelRepository,
DocumentsCubit>(
update:
(context, docApi, notifier, labelRepo, previous) =>
DocumentsCubit(
docApi,
notifier,
labelRepo,
Hive.box<LocalUserAppState>(HiveBoxes.localUserAppState)
.get(currentLocalUserId)!,
)..initialize(),
),
Provider(
create: (context) =>
DocumentScannerCubit(context.read())),
ProxyProvider4<
PaperlessDocumentsApi,
PaperlessServerStatsApi,
LabelRepository,
DocumentChangedNotifier,
InboxCubit>(
update: (context, docApi, statsApi, labelRepo, notifier,
previous) =>
InboxCubit(
docApi,
statsApi,
labelRepo,
notifier,
)..initialize(),
),
ProxyProvider<SavedViewRepository, SavedViewCubit>(
update: (context, savedViewRepo, previous) =>
SavedViewCubit(savedViewRepo),
),
ProxyProvider<LabelRepository, LabelCubit>(
update: (context, value, previous) => LabelCubit(value),
),
ProxyProvider<PaperlessTasksApi, TaskStatusCubit>(
update: (context, value, previous) =>
TaskStatusCubit(value),
),
if (paperlessApiVersion >= 3)
ProxyProvider<PaperlessUserApiV3, UserRepository>(
update: (context, value, previous) =>
UserRepository(value)..initialize(),
),
],
child: HomePage(paperlessApiVersion: paperlessApiVersion),
);
},
);
},
);
},
);
}
}
@@ -0,0 +1,217 @@
import 'package:flutter/material.dart';
import 'package:flutter_cache_manager/flutter_cache_manager.dart';
import 'package:hive_flutter/adapters.dart';
import 'package:paperless_api/paperless_api.dart';
import 'package:paperless_mobile/core/config/hive/hive_config.dart';
import 'package:paperless_mobile/core/config/hive/hive_extensions.dart';
import 'package:paperless_mobile/core/database/tables/local_user_app_state.dart';
import 'package:paperless_mobile/core/factory/paperless_api_factory.dart';
import 'package:paperless_mobile/core/repository/label_repository.dart';
import 'package:paperless_mobile/core/repository/saved_view_repository.dart';
import 'package:paperless_mobile/core/repository/user_repository.dart';
import 'package:paperless_mobile/core/security/session_manager.dart';
import 'package:paperless_mobile/core/service/dio_file_service.dart';
import 'package:paperless_mobile/features/document_scan/cubit/document_scanner_cubit.dart';
import 'package:paperless_mobile/features/documents/cubit/documents_cubit.dart';
import 'package:paperless_mobile/features/home/view/model/api_version.dart';
import 'package:paperless_mobile/features/inbox/cubit/inbox_cubit.dart';
import 'package:paperless_mobile/features/labels/cubit/label_cubit.dart';
import 'package:paperless_mobile/features/saved_view/cubit/saved_view_cubit.dart';
import 'package:paperless_mobile/features/settings/view/widgets/global_settings_builder.dart';
import 'package:paperless_mobile/features/tasks/model/pending_tasks_notifier.dart';
import 'package:provider/provider.dart';
class HomeShellWidget extends StatelessWidget {
/// The id of the currently authenticated user (e.g. demo@paperless.example.com)
final String localUserId;
/// The Paperless API version of the currently connected instance
final int paperlessApiVersion;
// A factory providing the API implementations given an API version
final PaperlessApiFactory paperlessProviderFactory;
final Widget child;
const HomeShellWidget({
super.key,
required this.paperlessApiVersion,
required this.paperlessProviderFactory,
required this.localUserId,
required this.child,
});
@override
Widget build(BuildContext context) {
return GlobalSettingsBuilder(
builder: (context, settings) {
final currentUserId = settings.loggedInUserId;
final apiVersion = ApiVersion(paperlessApiVersion);
return ValueListenableBuilder(
valueListenable:
Hive.localUserAccountBox.listenable(keys: [currentUserId]),
builder: (context, box, _) {
if (currentUserId == null) {
//This only happens during logout...
//TODO: Find way so this does not occur anymore
return SizedBox.shrink();
}
final currentLocalUser = box.get(currentUserId)!;
return MultiProvider(
key: ValueKey(currentUserId),
providers: [
Provider.value(value: currentLocalUser),
Provider.value(value: apiVersion),
Provider(
create: (context) => CacheManager(
Config(
// Isolated cache per user.
localUserId,
fileService:
DioFileService(context.read<SessionManager>().client),
),
),
),
Provider(
create: (context) =>
paperlessProviderFactory.createDocumentsApi(
context.read<SessionManager>().client,
apiVersion: paperlessApiVersion,
),
),
Provider(
create: (context) => paperlessProviderFactory.createLabelsApi(
context.read<SessionManager>().client,
apiVersion: paperlessApiVersion,
),
),
Provider(
create: (context) =>
paperlessProviderFactory.createSavedViewsApi(
context.read<SessionManager>().client,
apiVersion: paperlessApiVersion,
),
),
Provider(
create: (context) =>
paperlessProviderFactory.createServerStatsApi(
context.read<SessionManager>().client,
apiVersion: paperlessApiVersion,
),
),
Provider(
create: (context) => paperlessProviderFactory.createTasksApi(
context.read<SessionManager>().client,
apiVersion: paperlessApiVersion,
),
),
if (currentLocalUser.hasMultiUserSupport)
Provider(
create: (context) => PaperlessUserApiV3Impl(
context.read<SessionManager>().client,
),
),
],
builder: (context, _) {
return MultiProvider(
providers: [
Provider(
create: (context) {
final repo = LabelRepository(context.read());
if (currentLocalUser
.paperlessUser.canViewCorrespondents) {
repo.findAllCorrespondents();
}
if (currentLocalUser
.paperlessUser.canViewDocumentTypes) {
repo.findAllDocumentTypes();
}
if (currentLocalUser.paperlessUser.canViewTags) {
repo.findAllTags();
}
if (currentLocalUser
.paperlessUser.canViewStoragePaths) {
repo.findAllStoragePaths();
}
return repo;
},
),
Provider(
create: (context) {
final repo = SavedViewRepository(context.read());
if (currentLocalUser.paperlessUser.canViewSavedViews) {
repo.initialize();
}
return repo;
},
),
],
builder: (context, _) {
return MultiProvider(
providers: [
Provider(
lazy: false,
create: (context) => DocumentsCubit(
context.read(),
context.read(),
context.read(),
Hive.box<LocalUserAppState>(
HiveBoxes.localUserAppState)
.get(currentUserId)!,
context.read(),
)..initialize(),
),
Provider(
create: (context) =>
DocumentScannerCubit(context.read())
..initialize(),
),
Provider(
create: (context) {
final inboxCubit = InboxCubit(
context.read(),
context.read(),
context.read(),
context.read(),
context.read(),
);
if (currentLocalUser.paperlessUser.canViewInbox) {
inboxCubit.initialize();
}
return inboxCubit;
},
),
Provider(
create: (context) => SavedViewCubit(
context.read(),
),
),
Provider(
create: (context) => LabelCubit(
context.read(),
),
),
ChangeNotifierProvider(
create: (context) => PendingTasksNotifier(
context.read(),
),
),
if (currentLocalUser.hasMultiUserSupport)
Provider(
create: (context) => UserRepository(
context.read(),
)..initialize(),
),
],
child: child,
);
},
);
},
);
},
);
},
);
}
}
@@ -1,7 +1,7 @@
class ApiVersion { class ApiVersion {
final int version; final int version;
ApiVersion(this.version); const ApiVersion(this.version);
bool get hasMultiUserSupport => version >= 3;
} }
@@ -1,41 +0,0 @@
import 'package:flutter/material.dart';
class RouteDescription {
final String label;
final Icon icon;
final Icon selectedIcon;
final Widget Function(Widget icon)? badgeBuilder;
final bool enabled;
RouteDescription({
required this.label,
required this.icon,
required this.selectedIcon,
this.badgeBuilder,
this.enabled = true,
});
NavigationDestination toNavigationDestination() {
return NavigationDestination(
label: label,
icon: badgeBuilder?.call(icon) ?? icon,
selectedIcon: badgeBuilder?.call(selectedIcon) ?? selectedIcon,
);
}
NavigationRailDestination toNavigationRailDestination() {
return NavigationRailDestination(
label: Text(label),
icon: icon,
selectedIcon: selectedIcon,
);
}
BottomNavigationBarItem toBottomNavigationBarItem() {
return BottomNavigationBarItem(
label: label,
icon: badgeBuilder?.call(icon) ?? icon,
activeIcon: badgeBuilder?.call(selectedIcon) ?? selectedIcon,
);
}
}
@@ -0,0 +1,151 @@
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:go_router/go_router.dart';
import 'package:paperless_api/paperless_api.dart';
import 'package:paperless_mobile/features/app_drawer/view/app_drawer.dart';
import 'package:paperless_mobile/features/inbox/cubit/inbox_cubit.dart';
import 'package:paperless_mobile/generated/l10n/app_localizations.dart';
import 'package:paperless_mobile/theme.dart';
class ScaffoldWithNavigationBar extends StatefulWidget {
final UserModel authenticatedUser;
final StatefulNavigationShell navigationShell;
const ScaffoldWithNavigationBar({
super.key,
required this.authenticatedUser,
required this.navigationShell,
});
@override
State<ScaffoldWithNavigationBar> createState() =>
ScaffoldWithNavigationBarState();
}
class ScaffoldWithNavigationBarState extends State<ScaffoldWithNavigationBar> {
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
return AnnotatedRegion<SystemUiOverlayStyle>(
value: buildOverlayStyle(theme),
child: Scaffold(
drawer: const AppDrawer(),
bottomNavigationBar: NavigationBar(
elevation: 3,
backgroundColor: Theme.of(context).colorScheme.surface,
selectedIndex: widget.navigationShell.currentIndex,
onDestinationSelected: (index) {
widget.navigationShell.goBranch(
index,
initialLocation: index == widget.navigationShell.currentIndex,
);
},
destinations: [
NavigationDestination(
icon: const Icon(Icons.home_outlined),
selectedIcon: Icon(
Icons.home,
color: theme.colorScheme.primary,
),
label: S.of(context)!.home,
),
_toggleDestination(
NavigationDestination(
icon: const Icon(Icons.description_outlined),
selectedIcon: Icon(
Icons.description,
color: theme.colorScheme.primary,
),
label: S.of(context)!.documents,
),
disableWhen: !widget.authenticatedUser.canViewDocuments,
),
_toggleDestination(
NavigationDestination(
icon: const Icon(Icons.document_scanner_outlined),
selectedIcon: Icon(
Icons.document_scanner,
color: theme.colorScheme.primary,
),
label: S.of(context)!.scanner,
),
disableWhen: !widget.authenticatedUser.canCreateDocuments,
),
_toggleDestination(
NavigationDestination(
icon: const Icon(Icons.sell_outlined),
selectedIcon: Icon(
Icons.sell,
color: theme.colorScheme.primary,
),
label: S.of(context)!.labels,
),
disableWhen: !widget.authenticatedUser.canViewAnyLabel,
),
_toggleDestination(
NavigationDestination(
icon: Builder(
builder: (context) {
return BlocBuilder<InboxCubit, InboxState>(
builder: (context, state) {
return Badge.count(
isLabelVisible: state.itemsInInboxCount > 0,
count: state.itemsInInboxCount,
child: const Icon(Icons.inbox_outlined),
);
},
);
},
),
selectedIcon: BlocBuilder<InboxCubit, InboxState>(
builder: (context, state) {
return Badge.count(
isLabelVisible: state.itemsInInboxCount > 0 &&
widget.authenticatedUser.canViewInbox,
count: state.itemsInInboxCount,
child: Icon(
Icons.inbox,
color: theme.colorScheme.primary,
),
);
},
),
label: S.of(context)!.inbox,
),
disableWhen: !widget.authenticatedUser.canViewInbox,
),
],
),
body: widget.navigationShell,
),
);
}
Widget _toggleDestination(
Widget destination, {
required bool disableWhen,
}) {
final disabledColor = Theme.of(context).disabledColor;
final disabledTheme = Theme.of(context).navigationBarTheme.copyWith(
labelTextStyle: MaterialStatePropertyAll(
Theme.of(context)
.textTheme
.labelSmall
?.copyWith(color: disabledColor),
),
iconTheme: MaterialStatePropertyAll(
Theme.of(context).iconTheme.copyWith(color: disabledColor),
),
);
if (disableWhen) {
return AbsorbPointer(
child: Theme(
data: Theme.of(context).copyWith(navigationBarTheme: disabledTheme),
child: destination,
),
);
}
return destination;
}
}
@@ -1,78 +0,0 @@
import 'package:flutter/material.dart';
import 'package:hydrated_bloc/hydrated_bloc.dart';
import 'package:paperless_mobile/core/repository/label_repository.dart';
import 'package:paperless_mobile/core/repository/saved_view_repository.dart';
import 'package:paperless_mobile/extensions/flutter_extensions.dart';
import 'package:paperless_mobile/features/login/cubit/authentication_cubit.dart';
import 'package:paperless_mobile/features/settings/view/widgets/user_settings_builder.dart';
import 'package:paperless_mobile/generated/l10n/app_localizations.dart';
import 'package:provider/provider.dart';
class VerifyIdentityPage extends StatelessWidget {
const VerifyIdentityPage({super.key});
@override
Widget build(BuildContext context) {
return Material(
child: Scaffold(
appBar: AppBar(
elevation: 0,
backgroundColor: Theme.of(context).colorScheme.background,
title: Text(S.of(context)!.verifyYourIdentity),
),
body: UserAccountBuilder(
builder: (context, settings) {
if (settings == null) {
return const SizedBox.shrink();
}
return Column(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text(S
.of(context)!
.useTheConfiguredBiometricFactorToAuthenticate)
.paddedSymmetrically(horizontal: 16),
const Icon(
Icons.fingerprint,
size: 96,
),
Wrap(
alignment: WrapAlignment.spaceBetween,
runAlignment: WrapAlignment.spaceBetween,
runSpacing: 8,
spacing: 8,
children: [
TextButton(
onPressed: () => _logout(context),
child: Text(
S.of(context)!.disconnect,
style: TextStyle(
color: Theme.of(context).colorScheme.error,
),
),
),
ElevatedButton(
onPressed: () => context
.read<AuthenticationCubit>()
.restoreSessionState(),
child: Text(S.of(context)!.verifyIdentity),
),
],
).padded(16),
],
);
},
),
),
);
}
void _logout(BuildContext context) {
context.read<AuthenticationCubit>().logout();
context.read<LabelRepository>().clear();
context.read<SavedViewRepository>().clear();
HydratedBloc.storage.clear();
}
}
+42 -15
View File
@@ -7,6 +7,7 @@ import 'package:paperless_api/paperless_api.dart';
import 'package:paperless_mobile/core/notifier/document_changed_notifier.dart'; import 'package:paperless_mobile/core/notifier/document_changed_notifier.dart';
import 'package:paperless_mobile/core/repository/label_repository.dart'; import 'package:paperless_mobile/core/repository/label_repository.dart';
import 'package:paperless_mobile/core/repository/label_repository_state.dart'; import 'package:paperless_mobile/core/repository/label_repository_state.dart';
import 'package:paperless_mobile/core/service/connectivity_status_service.dart';
import 'package:paperless_mobile/features/paged_document_view/cubit/paged_documents_state.dart'; import 'package:paperless_mobile/features/paged_document_view/cubit/paged_documents_state.dart';
import 'package:paperless_mobile/features/paged_document_view/cubit/document_paging_bloc_mixin.dart'; import 'package:paperless_mobile/features/paged_document_view/cubit/document_paging_bloc_mixin.dart';
@@ -19,6 +20,9 @@ class InboxCubit extends HydratedCubit<InboxState>
final PaperlessDocumentsApi _documentsApi; final PaperlessDocumentsApi _documentsApi;
@override
final ConnectivityStatusService connectivityStatusService;
@override @override
final DocumentChangedNotifier notifier; final DocumentChangedNotifier notifier;
@@ -32,21 +36,35 @@ class InboxCubit extends HydratedCubit<InboxState>
this._statsApi, this._statsApi,
this._labelRepository, this._labelRepository,
this.notifier, this.notifier,
) : super(InboxState( this.connectivityStatusService,
labels: _labelRepository.state, ) : super(InboxState(labels: _labelRepository.state)) {
)) {
notifier.addListener( notifier.addListener(
this, this,
onDeleted: remove, onDeleted: remove,
onUpdated: (document) { onUpdated: (document) {
if (document.tags final hasInboxTag = document.tags
.toSet() .toSet()
.intersection(state.inboxTags.toSet()) .intersection(state.inboxTags.toSet())
.isEmpty) { .isNotEmpty;
final wasInInboxBeforeUpdate =
state.documents.map((e) => e.id).contains(document.id);
if (!hasInboxTag && wasInInboxBeforeUpdate) {
print(
"INBOX: Removing document: has: $hasInboxTag, had: $wasInInboxBeforeUpdate");
remove(document); remove(document);
emit(state.copyWith(itemsInInboxCount: state.itemsInInboxCount - 1)); emit(state.copyWith(itemsInInboxCount: state.itemsInInboxCount - 1));
} else { } else if (hasInboxTag) {
replace(document); if (wasInInboxBeforeUpdate) {
print(
"INBOX: Replacing document: has: $hasInboxTag, had: $wasInInboxBeforeUpdate");
replace(document);
} else {
print(
"INBOX: Adding document: has: $hasInboxTag, had: $wasInInboxBeforeUpdate");
_addDocument(document);
emit(
state.copyWith(itemsInInboxCount: state.itemsInInboxCount + 1));
}
} }
}, },
); );
@@ -58,22 +76,20 @@ class InboxCubit extends HydratedCubit<InboxState>
); );
} }
@override
Future<void> initialize() async { Future<void> initialize() async {
await refreshItemsInInboxCount(false); await refreshItemsInInboxCount(false);
await loadInbox(); await loadInbox();
} }
Future<void> refreshItemsInInboxCount([bool shouldLoadInbox = true]) async { Future<void> refreshItemsInInboxCount([bool shouldLoadInbox = true]) async {
debugPrint("Checking for new items in inbox...");
final stats = await _statsApi.getServerStatistics(); final stats = await _statsApi.getServerStatistics();
if (stats.documentsInInbox != state.itemsInInboxCount && shouldLoadInbox) { if (stats.documentsInInbox != state.itemsInInboxCount && shouldLoadInbox) {
await loadInbox(); await loadInbox();
} }
emit( emit(state.copyWith(itemsInInboxCount: stats.documentsInInbox));
state.copyWith(
itemsInInboxCount: stats.documentsInInbox,
),
);
} }
/// ///
@@ -82,7 +98,6 @@ class InboxCubit extends HydratedCubit<InboxState>
Future<void> loadInbox() async { Future<void> loadInbox() async {
if (!isClosed) { if (!isClosed) {
debugPrint("Initializing inbox..."); debugPrint("Initializing inbox...");
final inboxTags = await _labelRepository.findAllTags().then( final inboxTags = await _labelRepository.findAllTags().then(
(tags) => tags.where((t) => t.isInboxTag).map((t) => t.id!), (tags) => tags.where((t) => t.isInboxTag).map((t) => t.id!),
); );
@@ -110,11 +125,22 @@ class InboxCubit extends HydratedCubit<InboxState>
} }
} }
Future<void> _addDocument(DocumentModel document) async {
emit(state.copyWith(
value: [
...state.value,
PagedSearchResult(
count: 1,
results: [document],
),
],
));
}
/// ///
/// Fetches inbox tag ids and loads the inbox items (documents). /// Fetches inbox tag ids and loads the inbox items (documents).
/// ///
Future<void> reloadInbox() async { Future<void> reloadInbox() async {
emit(state.copyWith(hasLoaded: false, isLoading: true));
final inboxTags = await _labelRepository.findAllTags().then( final inboxTags = await _labelRepository.findAllTags().then(
(tags) => tags.where((t) => t.isInboxTag).map((t) => t.id!), (tags) => tags.where((t) => t.isInboxTag).map((t) => t.id!),
); );
@@ -131,6 +157,7 @@ class InboxCubit extends HydratedCubit<InboxState>
} }
emit(state.copyWith(inboxTags: inboxTags)); emit(state.copyWith(inboxTags: inboxTags));
updateFilter( updateFilter(
emitLoading: false,
filter: DocumentFilter( filter: DocumentFilter(
sortField: SortField.added, sortField: SortField.added,
tags: TagsQuery.ids(include: inboxTags.toList()), tags: TagsQuery.ids(include: inboxTags.toList()),
@@ -151,7 +178,7 @@ class InboxCubit extends HydratedCubit<InboxState>
document.copyWith(tags: updatedTags), document.copyWith(tags: updatedTags),
); );
// Remove first so document is not replaced first. // Remove first so document is not replaced first.
remove(document); // remove(document);
notifier.notifyUpdated(updatedDocument); notifier.notifyUpdated(updatedDocument);
return tagsToRemove; return tagsToRemove;
} }
+92 -23
View File
@@ -5,6 +5,7 @@ import 'package:intl/intl.dart';
import 'package:paperless_api/paperless_api.dart'; import 'package:paperless_api/paperless_api.dart';
import 'package:paperless_mobile/core/database/tables/local_user_account.dart'; import 'package:paperless_mobile/core/database/tables/local_user_account.dart';
import 'package:paperless_mobile/core/exception/server_message_exception.dart'; import 'package:paperless_mobile/core/exception/server_message_exception.dart';
import 'package:paperless_mobile/core/service/connectivity_status_service.dart';
import 'package:paperless_mobile/core/widgets/dialog_utils/dialog_cancel_button.dart'; import 'package:paperless_mobile/core/widgets/dialog_utils/dialog_cancel_button.dart';
import 'package:paperless_mobile/core/widgets/dialog_utils/dialog_confirm_button.dart'; import 'package:paperless_mobile/core/widgets/dialog_utils/dialog_confirm_button.dart';
import 'package:paperless_mobile/core/widgets/hint_card.dart'; import 'package:paperless_mobile/core/widgets/hint_card.dart';
@@ -17,6 +18,7 @@ import 'package:paperless_mobile/features/inbox/view/widgets/inbox_empty_widget.
import 'package:paperless_mobile/features/inbox/view/widgets/inbox_item.dart'; import 'package:paperless_mobile/features/inbox/view/widgets/inbox_item.dart';
import 'package:paperless_mobile/features/paged_document_view/view/document_paging_view_mixin.dart'; import 'package:paperless_mobile/features/paged_document_view/view/document_paging_view_mixin.dart';
import 'package:paperless_mobile/generated/l10n/app_localizations.dart'; import 'package:paperless_mobile/generated/l10n/app_localizations.dart';
import 'package:paperless_mobile/helpers/connectivity_aware_action_wrapper.dart';
import 'package:paperless_mobile/helpers/message_helpers.dart'; import 'package:paperless_mobile/helpers/message_helpers.dart';
class InboxPage extends StatefulWidget { class InboxPage extends StatefulWidget {
@@ -33,42 +35,99 @@ class _InboxPageState extends State<InboxPage>
@override @override
final pagingScrollController = ScrollController(); final pagingScrollController = ScrollController();
final _nestedScrollViewKey = GlobalKey<NestedScrollViewState>();
final _emptyStateRefreshIndicatorKey = GlobalKey<RefreshIndicatorState>(); final _emptyStateRefreshIndicatorKey = GlobalKey<RefreshIndicatorState>();
final _scrollController = ScrollController(); final _scrollController = ScrollController();
bool _showExtendedFab = true;
@override
void initState() {
super.initState();
context.read<InboxCubit>().reloadInbox();
WidgetsBinding.instance.addPostFrameCallback((_) {
_nestedScrollViewKey.currentState!.innerController
.addListener(_scrollExtentChangedListener);
});
}
@override
void dispose() {
_nestedScrollViewKey.currentState?.innerController
.removeListener(_scrollExtentChangedListener);
super.dispose();
}
void _scrollExtentChangedListener() {
const threshold = 400;
final offset =
_nestedScrollViewKey.currentState!.innerController.position.pixels;
if (offset < threshold && _showExtendedFab == false) {
setState(() {
_showExtendedFab = true;
});
} else if (offset >= threshold && _showExtendedFab == true) {
setState(() {
_showExtendedFab = false;
});
}
}
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final canEditDocument = final canEditDocument =
LocalUserAccount.current.paperlessUser.canEditDocuments; context.watch<LocalUserAccount>().paperlessUser.canEditDocuments;
return Scaffold( return Scaffold(
drawer: const AppDrawer(), drawer: const AppDrawer(),
floatingActionButton: BlocBuilder<InboxCubit, InboxState>( floatingActionButton: ConnectivityAwareActionWrapper(
builder: (context, state) { offlineBuilder: (context, child) => const SizedBox.shrink(),
if (!state.hasLoaded || state.documents.isEmpty || !canEditDocument) { child: BlocBuilder<InboxCubit, InboxState>(
return const SizedBox.shrink(); builder: (context, state) {
} if (!state.hasLoaded ||
return FloatingActionButton.extended( state.documents.isEmpty ||
label: Text(S.of(context)!.allSeen), !canEditDocument) {
icon: const Icon(Icons.done_all), return const SizedBox.shrink();
onPressed: state.hasLoaded && state.documents.isNotEmpty }
? () => _onMarkAllAsSeen( return FloatingActionButton.extended(
state.documents, extendedPadding: _showExtendedFab
state.inboxTags, ? null
) : const EdgeInsets.symmetric(horizontal: 16),
: null, heroTag: "inbox_page_fab",
); label: AnimatedSwitcher(
}, duration: const Duration(milliseconds: 200),
transitionBuilder: (child, animation) {
return FadeTransition(
opacity: animation,
child: SizeTransition(
sizeFactor: animation,
axis: Axis.horizontal,
child: child,
),
);
},
child: _showExtendedFab
? Row(
children: [
const Icon(Icons.done_all),
Text(S.of(context)!.allSeen),
],
)
: const Icon(Icons.done_all),
),
onPressed: state.hasLoaded && state.documents.isNotEmpty
? () => _onMarkAllAsSeen(
state.documents,
state.inboxTags,
)
: null,
);
},
),
), ),
body: SafeArea( body: SafeArea(
top: true, top: true,
child: NestedScrollView( child: NestedScrollView(
key: _nestedScrollViewKey,
headerSliverBuilder: (context, innerBoxIsScrolled) => [ headerSliverBuilder: (context, innerBoxIsScrolled) => [
SliverOverlapAbsorber( SliverSearchBar(titleText: S.of(context)!.inbox),
handle: searchBarHandle,
sliver: SliverSearchBar(
titleText: S.of(context)!.inbox,
),
)
], ],
body: BlocBuilder<InboxCubit, InboxState>( body: BlocBuilder<InboxCubit, InboxState>(
builder: (_, state) { builder: (_, state) {
@@ -213,6 +272,16 @@ class _InboxPageState extends State<InboxPage>
} }
Future<bool> _onItemDismissed(DocumentModel doc) async { Future<bool> _onItemDismissed(DocumentModel doc) async {
if (!context.read<LocalUserAccount>().paperlessUser.canEditDocuments) {
showSnackBar(context, S.of(context)!.missingPermissions);
return false;
}
final isConnectedToInternet =
await context.read<ConnectivityStatusService>().isConnectedToInternet();
if (!isConnectedToInternet) {
showSnackBar(context, S.of(context)!.youAreCurrentlyOffline);
return false;
}
try { try {
final removedTags = await context.read<InboxCubit>().removeFromInbox(doc); final removedTags = await context.read<InboxCubit>().removeFromInbox(doc);
showSnackBar( showSnackBar(
@@ -3,7 +3,6 @@ import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:paperless_api/paperless_api.dart'; import 'package:paperless_api/paperless_api.dart';
import 'package:paperless_mobile/core/database/tables/local_user_account.dart'; import 'package:paperless_mobile/core/database/tables/local_user_account.dart';
import 'package:paperless_mobile/core/navigation/push_routes.dart';
import 'package:paperless_mobile/core/widgets/shimmer_placeholder.dart'; import 'package:paperless_mobile/core/widgets/shimmer_placeholder.dart';
import 'package:paperless_mobile/core/workarounds/colored_chip.dart'; import 'package:paperless_mobile/core/workarounds/colored_chip.dart';
import 'package:paperless_mobile/extensions/flutter_extensions.dart'; import 'package:paperless_mobile/extensions/flutter_extensions.dart';
@@ -15,6 +14,8 @@ import 'package:paperless_mobile/features/inbox/cubit/inbox_cubit.dart';
import 'package:paperless_mobile/features/labels/tags/view/widgets/tags_widget.dart'; import 'package:paperless_mobile/features/labels/tags/view/widgets/tags_widget.dart';
import 'package:paperless_mobile/features/labels/view/widgets/label_text.dart'; import 'package:paperless_mobile/features/labels/view/widgets/label_text.dart';
import 'package:paperless_mobile/generated/l10n/app_localizations.dart'; import 'package:paperless_mobile/generated/l10n/app_localizations.dart';
import 'package:paperless_mobile/helpers/connectivity_aware_action_wrapper.dart';
import 'package:paperless_mobile/routes/typed/branches/documents_route.dart';
class InboxItemPlaceholder extends StatelessWidget { class InboxItemPlaceholder extends StatelessWidget {
const InboxItemPlaceholder({super.key}); const InboxItemPlaceholder({super.key});
@@ -150,11 +151,10 @@ class _InboxItemState extends State<InboxItem> {
return GestureDetector( return GestureDetector(
behavior: HitTestBehavior.translucent, behavior: HitTestBehavior.translucent,
onTap: () { onTap: () {
pushDocumentDetailsRoute( DocumentDetailsRoute(
context, $extra: widget.document,
document: widget.document,
isLabelClickable: false, isLabelClickable: false,
); ).push(context);
}, },
child: SizedBox( child: SizedBox(
height: 200, height: 200,
@@ -227,7 +227,9 @@ class _InboxItemState extends State<InboxItem> {
), ),
LimitedBox( LimitedBox(
maxHeight: 56, maxHeight: 56,
child: _buildActions(context), child: ConnectivityAwareActionWrapper(
child: _buildActions(context),
),
), ),
], ],
).paddedOnly(left: 8, top: 8, bottom: 8), ).paddedOnly(left: 8, top: 8, bottom: 8),
@@ -238,8 +240,9 @@ class _InboxItemState extends State<InboxItem> {
} }
Widget _buildActions(BuildContext context) { Widget _buildActions(BuildContext context) {
final canEdit = LocalUserAccount.current.paperlessUser.canEditDocuments; final currentUser = context.watch<LocalUserAccount>().paperlessUser;
final canDelete = LocalUserAccount.current.paperlessUser.canDeleteDocuments; final canEdit = currentUser.canEditDocuments;
final canDelete = currentUser.canDeleteDocuments;
final chipShape = RoundedRectangleBorder( final chipShape = RoundedRectangleBorder(
borderRadius: BorderRadius.circular(32), borderRadius: BorderRadius.circular(32),
); );
+10 -1
View File
@@ -4,8 +4,8 @@ import 'package:paperless_api/paperless_api.dart';
import 'package:paperless_mobile/core/repository/label_repository.dart'; import 'package:paperless_mobile/core/repository/label_repository.dart';
import 'package:paperless_mobile/features/labels/cubit/label_cubit_mixin.dart'; import 'package:paperless_mobile/features/labels/cubit/label_cubit_mixin.dart';
part 'label_state.dart';
part 'label_cubit.freezed.dart'; part 'label_cubit.freezed.dart';
part 'label_state.dart';
class LabelCubit extends Cubit<LabelState> with LabelCubitMixin<LabelState> { class LabelCubit extends Cubit<LabelState> with LabelCubitMixin<LabelState> {
@override @override
@@ -25,6 +25,15 @@ class LabelCubit extends Cubit<LabelState> with LabelCubitMixin<LabelState> {
); );
} }
Future<void> reload() {
return Future.wait([
labelRepository.findAllCorrespondents(),
labelRepository.findAllDocumentTypes(),
labelRepository.findAllTags(),
labelRepository.findAllStoragePaths(),
]);
}
@override @override
Future<void> close() { Future<void> close() {
labelRepository.removeListener(this); labelRepository.removeListener(this);
@@ -1,35 +0,0 @@
import 'package:flutter/material.dart';
import 'package:paperless_api/paperless_api.dart';
class StoragePathWidget extends StatelessWidget {
final StoragePath? storagePath;
final Color? textColor;
final bool isClickable;
final void Function(int? id)? onSelected;
const StoragePathWidget({
Key? key,
this.storagePath,
this.textColor,
this.isClickable = true,
this.onSelected,
}) : super(key: key);
@override
Widget build(BuildContext context) {
return AbsorbPointer(
absorbing: !isClickable,
child: GestureDetector(
onTap: () => onSelected?.call(storagePath?.id),
child: Text(
storagePath?.name ?? "-",
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
color: textColor ?? Theme.of(context).colorScheme.primary,
),
),
),
);
}
}
@@ -1,5 +1,6 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:paperless_api/paperless_api.dart'; import 'package:paperless_api/paperless_api.dart';
import 'package:paperless_mobile/extensions/flutter_extensions.dart';
import 'package:paperless_mobile/features/edit_label/view/impl/add_tag_page.dart'; import 'package:paperless_mobile/features/edit_label/view/impl/add_tag_page.dart';
import 'package:paperless_mobile/generated/l10n/app_localizations.dart'; import 'package:paperless_mobile/generated/l10n/app_localizations.dart';
@@ -68,10 +69,12 @@ class _FullscreenTagsFormState extends State<FullscreenTagsForm> {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final showFab = MediaQuery.viewInsetsOf(context).bottom == 0;
final theme = Theme.of(context); final theme = Theme.of(context);
return Scaffold( return Scaffold(
floatingActionButton: widget.allowCreation floatingActionButton: widget.allowCreation && showFab
? FloatingActionButton( ? FloatingActionButton(
heroTag: "fab_tags_form",
onPressed: _onAddTag, onPressed: _onAddTag,
child: const Icon(Icons.add), child: const Icon(Icons.add),
) )
@@ -191,7 +194,7 @@ class _FullscreenTagsFormState extends State<FullscreenTagsForm> {
final createdTag = await Navigator.of(context).push<Tag?>( final createdTag = await Navigator.of(context).push<Tag?>(
MaterialPageRoute( MaterialPageRoute(
builder: (context) => AddTagPage( builder: (context) => AddTagPage(
initialValue: _textEditingController.text, initialName: _textEditingController.text,
), ),
), ),
); );
@@ -237,10 +240,16 @@ class _FullscreenTagsFormState extends State<FullscreenTagsForm> {
var matches = _options var matches = _options
.where((e) => e.name.trim().toLowerCase().contains(normalizedQuery)); .where((e) => e.name.trim().toLowerCase().contains(normalizedQuery));
if (matches.isEmpty && widget.allowCreation) { if (matches.isEmpty && widget.allowCreation) {
yield Text(S.of(context)!.noItemsFound); yield Center(
yield TextButton( child: Column(
child: Text(S.of(context)!.addTag), children: [
onPressed: _onAddTag, Text(S.of(context)!.noItemsFound).padded(),
TextButton(
child: Text(S.of(context)!.addTag),
onPressed: _onAddTag,
),
],
),
); );
} }
for (final tag in matches) { for (final tag in matches) {
@@ -1,6 +1,7 @@
import 'package:animations/animations.dart'; import 'package:animations/animations.dart';
import 'package:collection/collection.dart'; import 'package:collection/collection.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:flutter_form_builder/flutter_form_builder.dart'; import 'package:flutter_form_builder/flutter_form_builder.dart';
import 'package:paperless_api/paperless_api.dart'; import 'package:paperless_api/paperless_api.dart';
import 'package:paperless_mobile/core/database/tables/local_user_account.dart'; import 'package:paperless_mobile/core/database/tables/local_user_account.dart';
@@ -73,7 +74,7 @@ class TagsFormField extends StatelessWidget {
initialValue: field.value, initialValue: field.value,
allowOnlySelection: allowOnlySelection, allowOnlySelection: allowOnlySelection,
allowCreation: allowCreation && allowCreation: allowCreation &&
LocalUserAccount.current.paperlessUser.canCreateTags, context.watch<LocalUserAccount>().paperlessUser.canCreateTags,
allowExclude: allowExclude, allowExclude: allowExclude,
), ),
onClosed: (data) { onClosed: (data) {
+150 -215
View File
@@ -7,23 +7,14 @@ import 'package:paperless_mobile/core/config/hive/hive_config.dart';
import 'package:paperless_mobile/core/database/tables/global_settings.dart'; import 'package:paperless_mobile/core/database/tables/global_settings.dart';
import 'package:paperless_mobile/core/database/tables/local_user_account.dart'; import 'package:paperless_mobile/core/database/tables/local_user_account.dart';
import 'package:paperless_mobile/core/delegate/customizable_sliver_persistent_header_delegate.dart'; import 'package:paperless_mobile/core/delegate/customizable_sliver_persistent_header_delegate.dart';
import 'package:paperless_mobile/core/repository/label_repository.dart';
import 'package:paperless_mobile/core/widgets/material/colored_tab_bar.dart'; import 'package:paperless_mobile/core/widgets/material/colored_tab_bar.dart';
import 'package:paperless_mobile/features/app_drawer/view/app_drawer.dart'; import 'package:paperless_mobile/features/app_drawer/view/app_drawer.dart';
import 'package:paperless_mobile/features/document_search/view/sliver_search_bar.dart'; import 'package:paperless_mobile/features/document_search/view/sliver_search_bar.dart';
import 'package:paperless_mobile/features/edit_label/view/impl/add_correspondent_page.dart';
import 'package:paperless_mobile/features/edit_label/view/impl/add_document_type_page.dart';
import 'package:paperless_mobile/features/edit_label/view/impl/add_storage_path_page.dart';
import 'package:paperless_mobile/features/edit_label/view/impl/add_tag_page.dart';
import 'package:paperless_mobile/features/edit_label/view/impl/edit_correspondent_page.dart';
import 'package:paperless_mobile/features/edit_label/view/impl/edit_document_type_page.dart';
import 'package:paperless_mobile/features/edit_label/view/impl/edit_storage_path_page.dart';
import 'package:paperless_mobile/features/edit_label/view/impl/edit_tag_page.dart';
import 'package:paperless_mobile/features/home/view/model/api_version.dart';
import 'package:paperless_mobile/features/labels/cubit/label_cubit.dart'; import 'package:paperless_mobile/features/labels/cubit/label_cubit.dart';
import 'package:paperless_mobile/features/labels/view/widgets/label_tab_view.dart'; import 'package:paperless_mobile/features/labels/view/widgets/label_tab_view.dart';
import 'package:paperless_mobile/generated/l10n/app_localizations.dart'; import 'package:paperless_mobile/generated/l10n/app_localizations.dart';
import 'package:provider/provider.dart'; import 'package:paperless_mobile/helpers/connectivity_aware_action_wrapper.dart';
import 'package:paperless_mobile/routes/typed/branches/labels_route.dart';
class LabelsPage extends StatefulWidget { class LabelsPage extends StatefulWidget {
const LabelsPage({Key? key}) : super(key: key); const LabelsPage({Key? key}) : super(key: key);
@@ -40,6 +31,7 @@ class _LabelsPageState extends State<LabelsPage>
SliverOverlapAbsorberHandle(); SliverOverlapAbsorberHandle();
late final TabController _tabController; late final TabController _tabController;
int _currentIndex = 0; int _currentIndex = 0;
int _calculateTabCount(UserModel user) => [ int _calculateTabCount(UserModel user) => [
@@ -52,12 +44,18 @@ class _LabelsPageState extends State<LabelsPage>
@override @override
void initState() { void initState() {
super.initState(); super.initState();
final user = LocalUserAccount.current.paperlessUser; final user = context.read<LocalUserAccount>().paperlessUser;
_tabController = TabController( _tabController = TabController(
length: _calculateTabCount(user), vsync: this) length: _calculateTabCount(user), vsync: this)
..addListener(() => setState(() => _currentIndex = _tabController.index)); ..addListener(() => setState(() => _currentIndex = _tabController.index));
} }
@override
void dispose() {
_tabController.dispose();
super.dispose();
}
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return ValueListenableBuilder( return ValueListenableBuilder(
@@ -67,22 +65,39 @@ class _LabelsPageState extends State<LabelsPage>
final currentUserId = final currentUserId =
Hive.box<GlobalSettings>(HiveBoxes.globalSettings) Hive.box<GlobalSettings>(HiveBoxes.globalSettings)
.getValue()! .getValue()!
.currentLoggedInUser; .loggedInUserId;
final user = box.get(currentUserId)!.paperlessUser; final user = box.get(currentUserId)!.paperlessUser;
final fabLabel = [
S.of(context)!.addCorrespondent,
S.of(context)!.addDocumentType,
S.of(context)!.addTag,
S.of(context)!.addStoragePath,
][_currentIndex];
return BlocBuilder<ConnectivityCubit, ConnectivityState>( return BlocBuilder<ConnectivityCubit, ConnectivityState>(
builder: (context, connectedState) { builder: (context, connectedState) {
return SafeArea( return SafeArea(
child: Scaffold( child: Scaffold(
drawer: const AppDrawer(), drawer: const AppDrawer(),
floatingActionButton: FloatingActionButton( floatingActionButton: ConnectivityAwareActionWrapper(
onPressed: [ offlineBuilder: (context, child) => const SizedBox.shrink(),
if (user.canViewCorrespondents) _openAddCorrespondentPage, child: FloatingActionButton.extended(
if (user.canViewDocumentTypes) _openAddDocumentTypePage, heroTag: "inbox_page_fab",
if (user.canViewTags) _openAddTagPage, label: Text(fabLabel),
if (user.canViewStoragePaths) _openAddStoragePathPage, icon: Icon(Icons.add),
][_currentIndex], onPressed: [
child: const Icon(Icons.add), if (user.canViewCorrespondents)
() => CreateLabelRoute(LabelType.correspondent)
.push(context),
if (user.canViewDocumentTypes)
() => CreateLabelRoute(LabelType.documentType)
.push(context),
if (user.canViewTags)
() => CreateLabelRoute(LabelType.tag).push(context),
if (user.canViewStoragePaths)
() => CreateLabelRoute(LabelType.storagePath)
.push(context),
][_currentIndex],
),
), ),
body: NestedScrollView( body: NestedScrollView(
floatHeaderSlivers: true, floatHeaderSlivers: true,
@@ -213,144 +228,13 @@ class _LabelsPageState extends State<LabelsPage>
controller: _tabController, controller: _tabController,
children: [ children: [
if (user.canViewCorrespondents) if (user.canViewCorrespondents)
Builder( _buildCorrespondentsView(state, user),
builder: (context) {
return CustomScrollView(
slivers: [
SliverOverlapInjector(
handle: searchBarHandle),
SliverOverlapInjector(
handle: tabBarHandle),
LabelTabView<Correspondent>(
labels: state.correspondents,
filterBuilder: (label) =>
DocumentFilter(
correspondent:
IdQueryParameter.fromId(
label.id!),
),
canEdit: user.canEditCorrespondents,
canAddNew:
user.canCreateCorrespondents,
onEdit: _openEditCorrespondentPage,
emptyStateActionButtonLabel: S
.of(context)!
.addNewCorrespondent,
emptyStateDescription: S
.of(context)!
.noCorrespondentsSetUp,
onAddNew: _openAddCorrespondentPage,
),
],
);
},
),
if (user.canViewDocumentTypes) if (user.canViewDocumentTypes)
Builder( _buildDocumentTypesView(state, user),
builder: (context) {
return CustomScrollView(
slivers: [
SliverOverlapInjector(
handle: searchBarHandle),
SliverOverlapInjector(
handle: tabBarHandle),
LabelTabView<DocumentType>(
labels: state.documentTypes,
filterBuilder: (label) =>
DocumentFilter(
documentType:
IdQueryParameter.fromId(
label.id!),
),
canEdit: user.canEditDocumentTypes,
canAddNew:
user.canCreateDocumentTypes,
onEdit: _openEditDocumentTypePage,
emptyStateActionButtonLabel: S
.of(context)!
.addNewDocumentType,
emptyStateDescription: S
.of(context)!
.noDocumentTypesSetUp,
onAddNew: _openAddDocumentTypePage,
),
],
);
},
),
if (user.canViewTags) if (user.canViewTags)
Builder( _buildTagsView(state, user),
builder: (context) {
return CustomScrollView(
slivers: [
SliverOverlapInjector(
handle: searchBarHandle),
SliverOverlapInjector(
handle: tabBarHandle),
LabelTabView<Tag>(
labels: state.tags,
filterBuilder: (label) =>
DocumentFilter(
tags: TagsQuery.ids(
include: [label.id!]),
),
canEdit: user.canEditTags,
canAddNew: user.canCreateTags,
onEdit: _openEditTagPage,
leadingBuilder: (t) => CircleAvatar(
backgroundColor: t.color,
child: t.isInboxTag
? Icon(
Icons.inbox,
color: t.textColor,
)
: null,
),
emptyStateActionButtonLabel:
S.of(context)!.addNewTag,
emptyStateDescription:
S.of(context)!.noTagsSetUp,
onAddNew: _openAddTagPage,
),
],
);
},
),
if (user.canViewStoragePaths) if (user.canViewStoragePaths)
Builder( _buildStoragePathView(state, user),
builder: (context) {
return CustomScrollView(
slivers: [
SliverOverlapInjector(
handle: searchBarHandle),
SliverOverlapInjector(
handle: tabBarHandle),
LabelTabView<StoragePath>(
labels: state.storagePaths,
onEdit: _openEditStoragePathPage,
filterBuilder: (label) =>
DocumentFilter(
storagePath:
IdQueryParameter.fromId(
label.id!),
),
canEdit: user.canEditStoragePaths,
canAddNew:
user.canCreateStoragePaths,
contentBuilder: (path) =>
Text(path.path),
emptyStateActionButtonLabel: S
.of(context)!
.addNewStoragePath,
emptyStateDescription: S
.of(context)!
.noStoragePathsSetUp,
onAddNew: _openAddStoragePathPage,
),
],
);
},
),
], ],
), ),
), ),
@@ -365,73 +249,124 @@ class _LabelsPageState extends State<LabelsPage>
}); });
} }
void _openEditCorrespondentPage(Correspondent correspondent) { Widget _buildCorrespondentsView(LabelState state, UserModel user) {
Navigator.push( return Builder(
context, builder: (context) {
_buildLabelPageRoute(EditCorrespondentPage(correspondent: correspondent)), return CustomScrollView(
slivers: [
SliverOverlapInjector(handle: searchBarHandle),
SliverOverlapInjector(handle: tabBarHandle),
LabelTabView<Correspondent>(
labels: state.correspondents,
filterBuilder: (label) => DocumentFilter(
correspondent: IdQueryParameter.fromId(label.id!),
),
canEdit: user.canEditCorrespondents,
canAddNew: user.canCreateCorrespondents,
onEdit: (correspondent) {
EditLabelRoute(correspondent).push(context);
},
emptyStateActionButtonLabel: S.of(context)!.addNewCorrespondent,
emptyStateDescription: S.of(context)!.noCorrespondentsSetUp,
onAddNew: () =>
CreateLabelRoute(LabelType.correspondent).push(context),
),
],
);
},
); );
} }
void _openEditDocumentTypePage(DocumentType docType) { Widget _buildDocumentTypesView(LabelState state, UserModel user) {
Navigator.push( return Builder(
context, builder: (context) {
_buildLabelPageRoute(EditDocumentTypePage(documentType: docType)), return CustomScrollView(
slivers: [
SliverOverlapInjector(handle: searchBarHandle),
SliverOverlapInjector(handle: tabBarHandle),
LabelTabView<DocumentType>(
labels: state.documentTypes,
filterBuilder: (label) => DocumentFilter(
documentType: IdQueryParameter.fromId(label.id!),
),
canEdit: user.canEditDocumentTypes,
canAddNew: user.canCreateDocumentTypes,
onEdit: (label) {
EditLabelRoute(label).push(context);
},
emptyStateActionButtonLabel: S.of(context)!.addNewDocumentType,
emptyStateDescription: S.of(context)!.noDocumentTypesSetUp,
onAddNew: () =>
CreateLabelRoute(LabelType.documentType).push(context),
),
],
);
},
); );
} }
void _openEditTagPage(Tag tag) { Widget _buildTagsView(LabelState state, UserModel user) {
Navigator.push( return Builder(
context, builder: (context) {
_buildLabelPageRoute(EditTagPage(tag: tag)), return CustomScrollView(
slivers: [
SliverOverlapInjector(handle: searchBarHandle),
SliverOverlapInjector(handle: tabBarHandle),
LabelTabView<Tag>(
labels: state.tags,
filterBuilder: (label) => DocumentFilter(
tags: TagsQuery.ids(include: [label.id!]),
),
canEdit: user.canEditTags,
canAddNew: user.canCreateTags,
onEdit: (label) {
EditLabelRoute(label).push(context);
},
leadingBuilder: (t) => CircleAvatar(
backgroundColor: t.color,
child: t.isInboxTag
? Icon(
Icons.inbox,
color: t.textColor,
)
: null,
),
emptyStateActionButtonLabel: S.of(context)!.addNewTag,
emptyStateDescription: S.of(context)!.noTagsSetUp,
onAddNew: () => CreateLabelRoute(LabelType.tag).push(context),
),
],
);
},
); );
} }
void _openEditStoragePathPage(StoragePath path) { Widget _buildStoragePathView(LabelState state, UserModel user) {
Navigator.push( return Builder(
context, builder: (context) {
_buildLabelPageRoute(EditStoragePathPage( return CustomScrollView(
storagePath: path, slivers: [
)), SliverOverlapInjector(handle: searchBarHandle),
); SliverOverlapInjector(handle: tabBarHandle),
} LabelTabView<StoragePath>(
labels: state.storagePaths,
void _openAddCorrespondentPage() { onEdit: (label) {
Navigator.push( EditLabelRoute(label).push(context);
context, },
_buildLabelPageRoute(const AddCorrespondentPage()), filterBuilder: (label) => DocumentFilter(
); storagePath: IdQueryParameter.fromId(label.id!),
} ),
canEdit: user.canEditStoragePaths,
void _openAddDocumentTypePage() { canAddNew: user.canCreateStoragePaths,
Navigator.push( contentBuilder: (path) => Text(path.path),
context, emptyStateActionButtonLabel: S.of(context)!.addNewStoragePath,
_buildLabelPageRoute(const AddDocumentTypePage()), emptyStateDescription: S.of(context)!.noStoragePathsSetUp,
); onAddNew: () =>
} CreateLabelRoute(LabelType.storagePath).push(context),
),
void _openAddTagPage() { ],
Navigator.push( );
context, },
_buildLabelPageRoute(const AddTagPage()),
);
}
void _openAddStoragePathPage() {
Navigator.push(
context,
_buildLabelPageRoute(const AddStoragePathPage()),
);
}
MaterialPageRoute<dynamic> _buildLabelPageRoute(Widget page) {
return MaterialPageRoute(
builder: (_) => MultiProvider(
providers: [
Provider.value(value: context.read<LabelRepository>()),
Provider.value(value: context.read<ApiVersion>())
],
child: page,
),
); );
} }
} }
@@ -69,6 +69,7 @@ class _FullscreenLabelFormState<T extends Label>
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final showFab = MediaQuery.viewInsetsOf(context).bottom == 0;
final theme = Theme.of(context); final theme = Theme.of(context);
final options = _filterOptionsByQuery(_textEditingController.text); final options = _filterOptionsByQuery(_textEditingController.text);
return Scaffold( return Scaffold(
@@ -124,6 +125,13 @@ class _FullscreenLabelFormState<T extends Label>
), ),
), ),
), ),
floatingActionButton: showFab && widget.onCreateNewLabel != null
? FloatingActionButton(
heroTag: "fab_label_form",
onPressed: _onCreateNewLabel,
child: const Icon(Icons.add),
)
: null,
body: Builder( body: Builder(
builder: (context) { builder: (context) {
return Column( return Column(
@@ -1,8 +1,9 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:paperless_api/paperless_api.dart'; import 'package:paperless_api/paperless_api.dart';
import 'package:paperless_mobile/core/database/tables/local_user_account.dart'; import 'package:paperless_mobile/core/database/tables/local_user_account.dart';
import 'package:paperless_mobile/core/navigation/push_routes.dart';
import 'package:paperless_mobile/helpers/format_helpers.dart'; import 'package:paperless_mobile/helpers/format_helpers.dart';
import 'package:paperless_mobile/routes/typed/branches/labels_route.dart';
class LabelItem<T extends Label> extends StatelessWidget { class LabelItem<T extends Label> extends StatelessWidget {
final T label; final T label;
@@ -36,14 +37,14 @@ class LabelItem<T extends Label> extends StatelessWidget {
Widget _buildReferencedDocumentsWidget(BuildContext context) { Widget _buildReferencedDocumentsWidget(BuildContext context) {
final canOpen = (label.documentCount ?? 0) > 0 && final canOpen = (label.documentCount ?? 0) > 0 &&
LocalUserAccount.current.paperlessUser.canViewDocuments; context.watch<LocalUserAccount>().paperlessUser.canViewDocuments;
return TextButton.icon( return TextButton.icon(
label: const Icon(Icons.link), label: const Icon(Icons.link),
icon: Text(formatMaxCount(label.documentCount)), icon: Text(formatMaxCount(label.documentCount)),
onPressed: canOpen onPressed: canOpen
? () { ? () {
final filter = filterBuilder(label); final filter = filterBuilder(label);
pushLinkedDocumentsView(context, filter: filter); LinkedDocumentsRoute(filter).push(context);
} }
: null, : null,
); );
@@ -44,7 +44,7 @@ class LabelTabView<T extends Label> extends StatelessWidget {
return BlocBuilder<ConnectivityCubit, ConnectivityState>( return BlocBuilder<ConnectivityCubit, ConnectivityState>(
builder: (context, connectivityState) { builder: (context, connectivityState) {
if (!connectivityState.isConnected) { if (!connectivityState.isConnected) {
return const OfflineWidget(); return const SliverFillRemaining(child: OfflineWidget());
} }
final sortedLabels = labels.values.toList()..sort(); final sortedLabels = labels.values.toList()..sort();
if (labels.isEmpty) { if (labels.isEmpty) {
@@ -76,9 +76,7 @@ class LabelTabView<T extends Label> extends StatelessWidget {
Text( Text(
translateMatchingAlgorithmName( translateMatchingAlgorithmName(
context, l.matchingAlgorithm) + context, l.matchingAlgorithm) +
((l.match?.isNotEmpty ?? false) (l.match.isNotEmpty ? ": ${l.match}" : ""),
? ": ${l.match}"
: ""),
maxLines: 2, maxLines: 2,
), ),
onOpenEditPage: canEdit ? onEdit : null, onOpenEditPage: canEdit ? onEdit : null,
@@ -8,7 +8,7 @@ class LabelText<T extends Label> extends StatelessWidget {
const LabelText({ const LabelText({
super.key, super.key,
this.style, this.style,
this.placeholder = "", this.placeholder = "-",
required this.label, required this.label,
}); });
+200
View File
@@ -0,0 +1,200 @@
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:paperless_api/paperless_api.dart';
import 'package:paperless_mobile/core/database/tables/local_user_account.dart';
import 'package:paperless_mobile/extensions/flutter_extensions.dart';
import 'package:paperless_mobile/features/app_drawer/view/app_drawer.dart';
import 'package:paperless_mobile/features/document_search/view/sliver_search_bar.dart';
import 'package:paperless_mobile/features/landing/view/widgets/expansion_card.dart';
import 'package:paperless_mobile/features/landing/view/widgets/mime_types_pie_chart.dart';
import 'package:paperless_mobile/features/saved_view/cubit/saved_view_cubit.dart';
import 'package:paperless_mobile/features/saved_view_details/view/saved_view_preview.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/inbox_route.dart';
class LandingPage extends StatefulWidget {
const LandingPage({super.key});
@override
State<LandingPage> createState() => _LandingPageState();
}
class _LandingPageState extends State<LandingPage> {
final _searchBarHandle = SliverOverlapAbsorberHandle();
@override
Widget build(BuildContext context) {
final currentUser = context.watch<LocalUserAccount>().paperlessUser;
return SafeArea(
child: Scaffold(
drawer: const AppDrawer(),
body: NestedScrollView(
headerSliverBuilder: (context, innerBoxIsScrolled) => [
SliverOverlapAbsorber(
handle: _searchBarHandle,
sliver: SliverSearchBar(
floating: true,
titleText: S.of(context)!.documents,
),
),
],
body: CustomScrollView(
slivers: [
SliverToBoxAdapter(
child: Text(
S.of(context)!.welcomeUser(
currentUser.fullName ?? currentUser.username),
textAlign: TextAlign.center,
style: Theme.of(context)
.textTheme
.displaySmall
?.copyWith(fontSize: 28),
).padded(24),
),
SliverToBoxAdapter(child: _buildStatisticsCard(context)),
if (currentUser.canViewSavedViews) ...[
SliverPadding(
padding: const EdgeInsets.fromLTRB(16, 16, 0, 8),
sliver: SliverToBoxAdapter(
child: Row(
children: [
Icon(
Icons.saved_search,
color: Theme.of(context).colorScheme.primary,
).paddedOnly(right: 8),
Text(
S.of(context)!.views,
style: Theme.of(context).textTheme.titleMedium,
),
],
),
),
),
BlocBuilder<SavedViewCubit, SavedViewState>(
builder: (context, state) {
return state.maybeWhen(
loaded: (savedViews) {
final dashboardViews = savedViews.values
.where((element) => element.showOnDashboard)
.toList();
if (dashboardViews.isEmpty) {
return SliverToBoxAdapter(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(S.of(context)!.noSavedViewOnHomepageHint)
.padded(),
TextButton.icon(
onPressed: () {},
icon: const Icon(Icons.add),
label: Text(S.of(context)!.newView),
)
],
).paddedOnly(left: 16),
);
}
return SliverList.builder(
itemBuilder: (context, index) {
return SavedViewPreview(
savedView: dashboardViews.elementAt(index),
expanded: index == 0,
);
},
itemCount: dashboardViews.length,
);
},
orElse: () => const SliverToBoxAdapter(
child: Center(
child: CircularProgressIndicator(),
),
),
);
},
),
],
],
),
),
),
);
}
Widget _buildStatisticsCard(BuildContext context) {
final currentUser = context.read<LocalUserAccount>().paperlessUser;
return ExpansionCard(
initiallyExpanded: false,
title: Text(
S.of(context)!.statistics,
style: Theme.of(context).textTheme.titleLarge,
),
content: FutureBuilder<PaperlessServerStatisticsModel>(
future: context.read<PaperlessServerStatsApi>().getServerStatistics(),
builder: (context, snapshot) {
if (!snapshot.hasData) {
return const Center(
child: CircularProgressIndicator(),
).paddedOnly(top: 8, bottom: 24);
}
final stats = snapshot.data!;
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Card(
color: Theme.of(context).colorScheme.surfaceVariant,
child: ListTile(
shape: Theme.of(context).cardTheme.shape,
titleTextStyle: Theme.of(context).textTheme.labelLarge,
title: Text(S.of(context)!.documentsInInbox),
onTap: currentUser.canViewInbox
? () => InboxRoute().go(context)
: null,
trailing: Text(
stats.documentsInInbox.toString(),
style: Theme.of(context).textTheme.labelLarge,
),
),
),
Card(
color: Theme.of(context).colorScheme.surfaceVariant,
child: ListTile(
shape: Theme.of(context).cardTheme.shape,
titleTextStyle: Theme.of(context).textTheme.labelLarge,
title: Text(S.of(context)!.totalDocuments),
onTap: currentUser.canViewDocuments
? () {
DocumentsRoute().go(context);
}
: null,
trailing: Text(
stats.documentsTotal.toString(),
style: Theme.of(context).textTheme.labelLarge,
),
),
),
Card(
color: Theme.of(context).colorScheme.surfaceVariant,
child: ListTile(
shape: Theme.of(context).cardTheme.shape,
titleTextStyle: Theme.of(context).textTheme.labelLarge,
title: Text(S.of(context)!.totalCharacters),
trailing: Text(
stats.totalChars.toString(),
style: Theme.of(context).textTheme.labelLarge,
),
),
),
AspectRatio(
aspectRatio: 1.3,
child: SizedBox(
width: 300,
child: MimeTypesPieChart(statistics: stats),
),
),
],
).padded(16);
},
),
);
}
}

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