mirror of
https://github.com/Xevion/paperless-mobile.git
synced 2026-01-31 08:25:00 -06:00
Merge branch 'feature/go_router_migration' into development
This commit is contained in:
@@ -3,7 +3,8 @@
|
||||
<application android:label="Paperless Mobile"
|
||||
android:name="${applicationName}"
|
||||
android:icon="@mipmap/ic_launcher"
|
||||
android:requestLegacyExternalStorage="true">
|
||||
android:requestLegacyExternalStorage="true"
|
||||
>
|
||||
<activity
|
||||
android:name=".MainActivity"
|
||||
android:exported="true"
|
||||
|
||||
@@ -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,3 +1,4 @@
|
||||
project_id: "568557"
|
||||
files: [
|
||||
{
|
||||
"source" : "/lib/l10n/intl_en.arb",
|
||||
|
||||
+39
-34
@@ -35,7 +35,7 @@ PODS:
|
||||
- DKPhotoGallery/Resource (0.0.17):
|
||||
- SDWebImage
|
||||
- SwiftyGif
|
||||
- edge_detection (1.1.1):
|
||||
- edge_detection (1.1.2):
|
||||
- Flutter
|
||||
- WeScan
|
||||
- file_picker (0.0.1):
|
||||
@@ -48,14 +48,16 @@ PODS:
|
||||
- Flutter
|
||||
- flutter_native_splash (0.0.1):
|
||||
- Flutter
|
||||
- flutter_pdfview (1.0.2):
|
||||
- Flutter
|
||||
- flutter_secure_storage (6.0.0):
|
||||
- Flutter
|
||||
- fluttertoast (0.0.2):
|
||||
- Flutter
|
||||
- Toast
|
||||
- FMDB (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):
|
||||
- Flutter
|
||||
- local_auth_ios (0.0.1):
|
||||
@@ -67,9 +69,9 @@ PODS:
|
||||
- path_provider_foundation (0.0.1):
|
||||
- Flutter
|
||||
- FlutterMacOS
|
||||
- pdfx (1.0.0):
|
||||
- permission_handler_apple (9.1.1):
|
||||
- Flutter
|
||||
- permission_handler_apple (9.0.4):
|
||||
- printing (1.0.0):
|
||||
- Flutter
|
||||
- ReachabilitySwift (5.0.0)
|
||||
- receive_sharing_intent (0.0.1):
|
||||
@@ -79,16 +81,15 @@ PODS:
|
||||
- SDWebImage/Core (5.13.5)
|
||||
- share_plus (0.0.1):
|
||||
- Flutter
|
||||
- shared_preferences_foundation (0.0.1):
|
||||
- Flutter
|
||||
- FlutterMacOS
|
||||
- sqflite (0.0.2):
|
||||
- sqflite (0.0.3):
|
||||
- Flutter
|
||||
- FMDB (>= 2.7.5)
|
||||
- SwiftyGif (5.4.3)
|
||||
- Toast (4.0.0)
|
||||
- url_launcher_ios (0.0.1):
|
||||
- Flutter
|
||||
- webview_flutter_wkwebview (0.0.1):
|
||||
- Flutter
|
||||
- WeScan (1.7.0)
|
||||
|
||||
DEPENDENCIES:
|
||||
@@ -100,20 +101,21 @@ DEPENDENCIES:
|
||||
- flutter_keyboard_visibility (from `.symlinks/plugins/flutter_keyboard_visibility/ios`)
|
||||
- flutter_local_notifications (from `.symlinks/plugins/flutter_local_notifications/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`)
|
||||
- in_app_review (from `.symlinks/plugins/in_app_review/ios`)
|
||||
- integration_test (from `.symlinks/plugins/integration_test/ios`)
|
||||
- local_auth_ios (from `.symlinks/plugins/local_auth_ios/ios`)
|
||||
- open_filex (from `.symlinks/plugins/open_filex/ios`)
|
||||
- package_info_plus (from `.symlinks/plugins/package_info_plus/ios`)
|
||||
- path_provider_foundation (from `.symlinks/plugins/path_provider_foundation/ios`)
|
||||
- pdfx (from `.symlinks/plugins/pdfx/ios`)
|
||||
- path_provider_foundation (from `.symlinks/plugins/path_provider_foundation/darwin`)
|
||||
- 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`)
|
||||
- share_plus (from `.symlinks/plugins/share_plus/ios`)
|
||||
- shared_preferences_foundation (from `.symlinks/plugins/shared_preferences_foundation/ios`)
|
||||
- sqflite (from `.symlinks/plugins/sqflite/ios`)
|
||||
- url_launcher_ios (from `.symlinks/plugins/url_launcher_ios/ios`)
|
||||
- webview_flutter_wkwebview (from `.symlinks/plugins/webview_flutter_wkwebview/ios`)
|
||||
|
||||
SPEC REPOS:
|
||||
trunk:
|
||||
@@ -143,10 +145,12 @@ EXTERNAL SOURCES:
|
||||
:path: ".symlinks/plugins/flutter_local_notifications/ios"
|
||||
flutter_native_splash:
|
||||
: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:
|
||||
:path: ".symlinks/plugins/fluttertoast/ios"
|
||||
in_app_review:
|
||||
:path: ".symlinks/plugins/in_app_review/ios"
|
||||
integration_test:
|
||||
:path: ".symlinks/plugins/integration_test/ios"
|
||||
local_auth_ios:
|
||||
@@ -156,54 +160,55 @@ EXTERNAL SOURCES:
|
||||
package_info_plus:
|
||||
:path: ".symlinks/plugins/package_info_plus/ios"
|
||||
path_provider_foundation:
|
||||
:path: ".symlinks/plugins/path_provider_foundation/ios"
|
||||
pdfx:
|
||||
:path: ".symlinks/plugins/pdfx/ios"
|
||||
:path: ".symlinks/plugins/path_provider_foundation/darwin"
|
||||
permission_handler_apple:
|
||||
:path: ".symlinks/plugins/permission_handler_apple/ios"
|
||||
printing:
|
||||
:path: ".symlinks/plugins/printing/ios"
|
||||
receive_sharing_intent:
|
||||
:path: ".symlinks/plugins/receive_sharing_intent/ios"
|
||||
share_plus:
|
||||
:path: ".symlinks/plugins/share_plus/ios"
|
||||
shared_preferences_foundation:
|
||||
:path: ".symlinks/plugins/shared_preferences_foundation/ios"
|
||||
sqflite:
|
||||
:path: ".symlinks/plugins/sqflite/ios"
|
||||
url_launcher_ios:
|
||||
:path: ".symlinks/plugins/url_launcher_ios/ios"
|
||||
webview_flutter_wkwebview:
|
||||
:path: ".symlinks/plugins/webview_flutter_wkwebview/ios"
|
||||
|
||||
SPEC CHECKSUMS:
|
||||
connectivity_plus: 413a8857dd5d9f1c399a39130850d02fe0feaf7e
|
||||
connectivity_plus: 07c49e96d7fc92bc9920617b83238c4d178b446a
|
||||
device_info_plus: e5c5da33f982a436e103237c0c85f9031142abed
|
||||
DKImagePickerController: b512c28220a2b8ac7419f21c491fc8534b7601ac
|
||||
DKPhotoGallery: fdfad5125a9fdda9cc57df834d49df790dbb4179
|
||||
edge_detection: fa02aa120e00d87ada0ca2430b6c6087a501b1e9
|
||||
edge_detection: b4fb239b018cefa79515a024d0bf3e559336de4e
|
||||
file_picker: ce3938a0df3cc1ef404671531facef740d03f920
|
||||
Flutter: f04841e97a9d0b0a8025694d0796dd46242b2854
|
||||
flutter_keyboard_visibility: 0339d06371254c3eb25eeb90ba8d17dca8f9c069
|
||||
flutter_local_notifications: 0c0b1ae97e741e1521e4c1629a459d04b9aec743
|
||||
flutter_native_splash: 52501b97d1c0a5f898d687f1646226c1f93c56ef
|
||||
fluttertoast: eb263d302cc92e04176c053d2385237e9f43fad0
|
||||
flutter_pdfview: 25f53dd6097661e6395b17de506e6060585946bd
|
||||
flutter_secure_storage: 23fc622d89d073675f2eaa109381aefbcf5a49be
|
||||
fluttertoast: fafc4fa4d01a6a9e4f772ecd190ffa525e9e2d9c
|
||||
FMDB: 2ce00b547f966261cd18927a3ddb07cb6f3db82a
|
||||
in_app_review: 318597b3a06c22bb46dc454d56828c85f444f99d
|
||||
integration_test: a1e7d09bd98eca2fc37aefd79d4f41ad37bdbbe5
|
||||
local_auth_ios: 0d333dde7780f669e66f19d2ff6005f3ea84008d
|
||||
integration_test: 13825b8a9334a850581300559b8839134b124670
|
||||
local_auth_ios: c6cf091ded637a88f24f86a8875d8b0f526e2605
|
||||
open_filex: 6e26e659846ec990262224a12ef1c528bb4edbe4
|
||||
package_info_plus: 6c92f08e1f853dc01228d6f553146438dafcd14e
|
||||
path_provider_foundation: 37748e03f12783f9de2cb2c4eadfaa25fe6d4852
|
||||
pdfx: 7b876b09de8b7a0bf444a4f82b439ffcff4ee1ec
|
||||
permission_handler_apple: 44366e37eaf29454a1e7b1b7d736c2cceaeb17ce
|
||||
package_info_plus: fd030dabf36271f146f1f3beacd48f564b0f17f7
|
||||
path_provider_foundation: 29f094ae23ebbca9d3d0cec13889cd9060c0e943
|
||||
permission_handler_apple: e76247795d700c14ea09e3a2d8855d41ee80a2e6
|
||||
printing: 233e1b73bd1f4a05615548e9b5a324c98588640b
|
||||
ReachabilitySwift: 985039c6f7b23a1da463388634119492ff86c825
|
||||
receive_sharing_intent: c0d87310754e74c0f9542947e7cbdf3a0335a3b1
|
||||
SDWebImage: 23d714cd599354ee7906dbae26dff89b421c4370
|
||||
share_plus: 056a1e8ac890df3e33cb503afffaf1e9b4fbae68
|
||||
shared_preferences_foundation: 297b3ebca31b34ec92be11acd7fb0ba932c822ca
|
||||
sqflite: 6d358c025f5b867b29ed92fc697fd34924e11904
|
||||
sqflite: 31f7eba61e3074736dff8807a9b41581e4f7f15a
|
||||
SwiftyGif: 6c3eafd0ce693cad58bb63d2b2fb9bacb8552780
|
||||
Toast: 91b396c56ee72a5790816f40d3a94dd357abc196
|
||||
url_launcher_ios: fb12c43172927bb5cf75aeebd073f883801f1993
|
||||
url_launcher_ios: 08a3dfac5fb39e8759aeb0abbd5d9480f30fc8b4
|
||||
webview_flutter_wkwebview: 2e2d318f21a5e036e2c3f26171342e95908bd60a
|
||||
WeScan: fed582f6c38014d529afb5aa9ffd1bad38fc72b7
|
||||
|
||||
PODFILE CHECKSUM: 7daa35cc908d9fba025075df27cb57a1ba1ebf13
|
||||
|
||||
COCOAPODS: 1.11.3
|
||||
COCOAPODS: 1.12.0
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -15,11 +15,9 @@ import 'package:paperless_mobile/features/settings/model/view_type.dart';
|
||||
class HiveBoxes {
|
||||
HiveBoxes._();
|
||||
static const globalSettings = 'globalSettings';
|
||||
static const authentication = 'authentication';
|
||||
static const localUserCredentials = 'localUserCredentials';
|
||||
static const localUserAccount = 'localUserAccount';
|
||||
static const localUserAppState = 'localUserAppState';
|
||||
static const localUserSettings = 'localUserSettings';
|
||||
static const hosts = 'hosts';
|
||||
}
|
||||
|
||||
|
||||
@@ -4,13 +4,19 @@ import 'dart:typed_data';
|
||||
|
||||
import 'package:flutter_secure_storage/flutter_secure_storage.dart';
|
||||
import 'package:hive_flutter/adapters.dart';
|
||||
import 'package:paperless_mobile/core/config/hive/hive_config.dart';
|
||||
import 'package:paperless_mobile/core/database/tables/global_settings.dart';
|
||||
import 'package:paperless_mobile/core/database/tables/local_user_account.dart';
|
||||
import 'package:paperless_mobile/core/database/tables/local_user_app_state.dart';
|
||||
|
||||
///
|
||||
/// Opens an encrypted box, calls [callback] with the now opened box, awaits
|
||||
/// [callback] to return and returns the calculated value. Closes the box after.
|
||||
///
|
||||
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 box = await Hive.openBox<T>(
|
||||
name,
|
||||
@@ -22,7 +28,11 @@ Future<R?> withEncryptedBox<T, R>(
|
||||
}
|
||||
|
||||
Future<Uint8List> _getEncryptedBoxKey() async {
|
||||
const secureStorage = FlutterSecureStorage();
|
||||
const secureStorage = FlutterSecureStorage(
|
||||
aOptions: AndroidOptions(
|
||||
encryptedSharedPreferences: true,
|
||||
),
|
||||
);
|
||||
if (!await secureStorage.containsKey(key: 'key')) {
|
||||
final key = Hive.generateSecureKey();
|
||||
|
||||
@@ -34,3 +44,14 @@ Future<Uint8List> _getEncryptedBoxKey() async {
|
||||
final key = (await secureStorage.read(key: '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;
|
||||
|
||||
@HiveField(4)
|
||||
String? currentLoggedInUser;
|
||||
String? loggedInUserId;
|
||||
|
||||
@HiveField(5)
|
||||
FileDownloadType defaultDownloadType;
|
||||
@@ -32,14 +32,18 @@ class GlobalSettings with HiveObjectMixin {
|
||||
@HiveField(7, defaultValue: false)
|
||||
bool enforceSinglePagePdfUpload;
|
||||
|
||||
@HiveField(8, defaultValue: false)
|
||||
bool skipDocumentPreprarationOnUpload;
|
||||
|
||||
GlobalSettings({
|
||||
required this.preferredLocaleSubtag,
|
||||
this.preferredThemeMode = ThemeMode.system,
|
||||
this.preferredColorSchemeOption = ColorSchemeOption.classic,
|
||||
this.showOnboarding = true,
|
||||
this.currentLoggedInUser,
|
||||
this.loggedInUserId,
|
||||
this.defaultDownloadType = FileDownloadType.alwaysAsk,
|
||||
this.defaultShareType = FileDownloadType.alwaysAsk,
|
||||
this.enforceSinglePagePdfUpload = false,
|
||||
this.skipDocumentPreprarationOnUpload = false,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1,8 +1,7 @@
|
||||
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_mobile/core/config/hive/hive_config.dart';
|
||||
import 'package:paperless_mobile/core/database/tables/local_user_settings.dart';
|
||||
|
||||
part 'local_user_account.g.dart';
|
||||
|
||||
@@ -20,16 +19,16 @@ class LocalUserAccount extends HiveObject {
|
||||
@HiveField(7)
|
||||
UserModel paperlessUser;
|
||||
|
||||
@HiveField(8, defaultValue: 2)
|
||||
int apiVersion;
|
||||
|
||||
LocalUserAccount({
|
||||
required this.id,
|
||||
required this.serverUrl,
|
||||
required this.settings,
|
||||
required this.paperlessUser,
|
||||
required this.apiVersion,
|
||||
});
|
||||
|
||||
static LocalUserAccount get current =>
|
||||
Hive.box<LocalUserAccount>(HiveBoxes.localUserAccount).get(
|
||||
Hive.box<GlobalSettings>(HiveBoxes.globalSettings)
|
||||
.getValue()!
|
||||
.currentLoggedInUser)!;
|
||||
bool get hasMultiUserSupport => apiVersion >= 3;
|
||||
}
|
||||
|
||||
@@ -43,7 +43,7 @@ class LocalUserAppState extends HiveObject {
|
||||
final currentLocalUserId =
|
||||
Hive.box<GlobalSettings>(HiveBoxes.globalSettings)
|
||||
.getValue()!
|
||||
.currentLoggedInUser!;
|
||||
.loggedInUserId!;
|
||||
return Hive.box<LocalUserAppState>(HiveBoxes.localUserAppState)
|
||||
.get(currentLocalUserId)!;
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
@@ -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 = {};
|
||||
|
||||
Stream<DocumentModel> get $updated => _updated.asBroadcastStream();
|
||||
|
||||
Stream<DocumentModel> get $deleted => _deleted.asBroadcastStream();
|
||||
|
||||
void notifyUpdated(DocumentModel updated) {
|
||||
debugPrint("Notifying updated document ${updated.id}");
|
||||
_updated.add(updated);
|
||||
|
||||
@@ -8,25 +8,26 @@ abstract class PersistentRepository<T> extends HydratedCubit<T> {
|
||||
PersistentRepository(T initialState) : super(initialState);
|
||||
|
||||
void addListener(
|
||||
Object source, {
|
||||
Object subscriber, {
|
||||
required void Function(T) onChanged,
|
||||
}) {
|
||||
onChanged(state);
|
||||
_subscribers.putIfAbsent(source, () {
|
||||
_subscribers.putIfAbsent(subscriber, () {
|
||||
return stream.listen((event) => onChanged(event));
|
||||
});
|
||||
}
|
||||
|
||||
void removeListener(Object source) async {
|
||||
await _subscribers[source]?.cancel();
|
||||
_subscribers.remove(source);
|
||||
_subscribers
|
||||
..[source]?.cancel()
|
||||
..remove(source);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> close() {
|
||||
_subscribers.forEach((key, subscription) {
|
||||
subscription.cancel();
|
||||
});
|
||||
for (final subscriber in _subscribers.values) {
|
||||
subscriber.cancel();
|
||||
}
|
||||
return super.close();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -35,6 +35,18 @@ class SavedViewRepository
|
||||
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 {
|
||||
await _initialized.future;
|
||||
await _api.delete(view);
|
||||
|
||||
@@ -4,6 +4,7 @@ import 'package:dio/dio.dart';
|
||||
import 'package:dio/io.dart';
|
||||
import 'package:flutter/material.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/retry_on_connection_change_interceptor.dart';
|
||||
import 'package:paperless_mobile/features/login/model/client_certificate.dart';
|
||||
@@ -37,6 +38,7 @@ class SessionManager extends ValueNotifier<Dio> {
|
||||
...interceptors,
|
||||
DioUnauthorizedInterceptor(),
|
||||
DioHttpErrorInterceptor(),
|
||||
DioOfflineInterceptor(),
|
||||
PrettyDioLogger(
|
||||
compact: true,
|
||||
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/features/login/model/client_certificate.dart';
|
||||
import 'package:paperless_mobile/features/login/model/reachability_status.dart';
|
||||
import 'package:rxdart/subjects.dart';
|
||||
|
||||
abstract class ConnectivityStatusService {
|
||||
Future<bool> isConnectedToInternet();
|
||||
@@ -20,14 +21,19 @@ abstract class ConnectivityStatusService {
|
||||
|
||||
class ConnectivityStatusServiceImpl implements ConnectivityStatusService {
|
||||
final Connectivity _connectivity;
|
||||
final BehaviorSubject<bool> _connectivityState$ = BehaviorSubject();
|
||||
|
||||
ConnectivityStatusServiceImpl(this._connectivity);
|
||||
ConnectivityStatusServiceImpl(this._connectivity) {
|
||||
_connectivityState$.addStream(
|
||||
_connectivity.onConnectivityChanged
|
||||
.map(_hasActiveInternetConnection)
|
||||
.asBroadcastStream(),
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Stream<bool> connectivityChanges() {
|
||||
return _connectivity.onConnectivityChanged
|
||||
.map(_hasActiveInternetConnection)
|
||||
.asBroadcastStream();
|
||||
return _connectivityState$.asBroadcastStream();
|
||||
}
|
||||
|
||||
@override
|
||||
@@ -98,3 +104,31 @@ class ConnectivityStatusServiceImpl implements ConnectivityStatusService {
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,34 +1,30 @@
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:paperless_api/paperless_api.dart';
|
||||
import 'package:path_provider/path_provider.dart';
|
||||
import 'package:rxdart/rxdart.dart';
|
||||
import 'package:uuid/uuid.dart';
|
||||
|
||||
class FileService {
|
||||
const FileService._();
|
||||
|
||||
static Future<File> saveToFile(
|
||||
Uint8List bytes,
|
||||
String filename,
|
||||
) async {
|
||||
final dir = await documentsDirectory;
|
||||
if (dir == null) {
|
||||
throw const PaperlessApiException.unknown(); //TODO: better handling
|
||||
}
|
||||
File file = File("${dir.path}/$filename");
|
||||
return file..writeAsBytes(bytes);
|
||||
}
|
||||
|
||||
static Future<Directory?> getDirectory(PaperlessDirectoryType type) {
|
||||
switch (type) {
|
||||
case PaperlessDirectoryType.documents:
|
||||
return documentsDirectory;
|
||||
case PaperlessDirectoryType.temporary:
|
||||
return temporaryDirectory;
|
||||
case PaperlessDirectoryType.scans:
|
||||
return scanDirectory;
|
||||
case PaperlessDirectoryType.download:
|
||||
return downloadsDirectory;
|
||||
}
|
||||
return switch (type) {
|
||||
PaperlessDirectoryType.documents => documentsDirectory,
|
||||
PaperlessDirectoryType.temporary => temporaryDirectory,
|
||||
PaperlessDirectoryType.scans => temporaryScansDirectory,
|
||||
PaperlessDirectoryType.download => downloadsDirectory,
|
||||
PaperlessDirectoryType.upload => uploadDirectory,
|
||||
};
|
||||
}
|
||||
|
||||
static Future<File> allocateTemporaryFile(
|
||||
@@ -43,17 +39,16 @@ class FileService {
|
||||
|
||||
static Future<Directory> get temporaryDirectory => getTemporaryDirectory();
|
||||
|
||||
static Future<Directory?> get documentsDirectory async {
|
||||
static Future<Directory> get documentsDirectory async {
|
||||
if (Platform.isAndroid) {
|
||||
return (await getExternalStorageDirectories(
|
||||
type: StorageDirectory.documents,
|
||||
))!
|
||||
.first;
|
||||
} else if (Platform.isIOS) {
|
||||
final appDir = await getApplicationDocumentsDirectory();
|
||||
final dir = Directory('${appDir.path}/documents');
|
||||
dir.createSync();
|
||||
return dir;
|
||||
final dir = await getApplicationDocumentsDirectory()
|
||||
.then((dir) => Directory('${dir.path}/documents'));
|
||||
return dir.create(recursive: true);
|
||||
} else {
|
||||
throw UnsupportedError("Platform not supported.");
|
||||
}
|
||||
@@ -72,34 +67,38 @@ class FileService {
|
||||
} else if (Platform.isIOS) {
|
||||
final appDir = await getApplicationDocumentsDirectory();
|
||||
final dir = Directory('${appDir.path}/downloads');
|
||||
dir.createSync();
|
||||
return dir;
|
||||
return dir.create(recursive: true);
|
||||
} else {
|
||||
throw UnsupportedError("Platform not supported.");
|
||||
}
|
||||
}
|
||||
|
||||
static Future<Directory?> get scanDirectory async {
|
||||
if (Platform.isAndroid) {
|
||||
final scanDir = await getExternalStorageDirectories(
|
||||
type: StorageDirectory.dcim,
|
||||
);
|
||||
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<Directory> get uploadDirectory async {
|
||||
final dir = await getApplicationDocumentsDirectory()
|
||||
.then((dir) => Directory('${dir.path}/upload'));
|
||||
return dir.create(recursive: true);
|
||||
}
|
||||
|
||||
static Future<void> clearUserData() async {
|
||||
final scanDir = await scanDirectory;
|
||||
static Future<Directory> getConsumptionDirectory(
|
||||
{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;
|
||||
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 consumptionDir.delete(recursive: true);
|
||||
}
|
||||
|
||||
static Future<void> clearDirectoryContent(PaperlessDirectoryType type) async {
|
||||
@@ -113,11 +112,20 @@ class FileService {
|
||||
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 {
|
||||
documents,
|
||||
temporary,
|
||||
scans,
|
||||
download;
|
||||
download,
|
||||
upload;
|
||||
}
|
||||
|
||||
@@ -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.acknowledgeTasksError => S.of(context)!.couldNotAcknowledgeTasks,
|
||||
ErrorCode.correspondentDeleteFailed =>
|
||||
"Could not delete correspondent, please try again.",
|
||||
S.of(context)!.couldNotDeleteCorrespondent,
|
||||
ErrorCode.documentTypeDeleteFailed =>
|
||||
"Could not delete document type, please try again.",
|
||||
ErrorCode.tagDeleteFailed => "Could not delete tag, please try again.",
|
||||
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.",
|
||||
S.of(context)!.couldNotDeleteDocumentType,
|
||||
ErrorCode.tagDeleteFailed => S.of(context)!.couldNotDeleteTag,
|
||||
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 =>
|
||||
"Could not update storage path, please try again.",
|
||||
S.of(context)!.couldNotUpdateStoragePath,
|
||||
ErrorCode.serverInformationLoadFailed =>
|
||||
"Could not load server information.",
|
||||
ErrorCode.serverStatisticsLoadFailed => "Could not load server statistics.",
|
||||
ErrorCode.uiSettingsLoadFailed => "Could not load UI settings",
|
||||
ErrorCode.loadTasksError => "Could not load tasks.",
|
||||
ErrorCode.userNotFound => "User could not be found.",
|
||||
S.of(context)!.couldNotLoadServerInformation,
|
||||
ErrorCode.serverStatisticsLoadFailed =>
|
||||
S.of(context)!.couldNotLoadStatistics,
|
||||
ErrorCode.uiSettingsLoadFailed => S.of(context)!.couldNotLoadUISettings,
|
||||
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,
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,16 +1,19 @@
|
||||
import 'package:flutter/cupertino.dart';
|
||||
import 'package:flutter/gestures.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_animate/flutter_animate.dart';
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
import 'package:flutter_svg/flutter_svg.dart';
|
||||
import 'package:paperless_api/paperless_api.dart';
|
||||
import 'package:paperless_mobile/constants.dart';
|
||||
import 'package:paperless_mobile/core/widgets/paperless_logo.dart';
|
||||
import 'package:paperless_mobile/extensions/flutter_extensions.dart';
|
||||
import 'package:paperless_mobile/features/home/view/model/api_version.dart';
|
||||
import 'package:paperless_mobile/features/settings/view/settings_page.dart';
|
||||
import 'package:paperless_mobile/features/documents/cubit/documents_cubit.dart';
|
||||
import 'package:paperless_mobile/features/saved_view/cubit/saved_view_cubit.dart';
|
||||
import 'package:paperless_mobile/features/sharing/cubit/receive_share_cubit.dart';
|
||||
import 'package:paperless_mobile/generated/l10n/app_localizations.dart';
|
||||
import 'package:paperless_mobile/routes/typed/branches/documents_route.dart';
|
||||
import 'package:paperless_mobile/routes/typed/branches/upload_queue_route.dart';
|
||||
import 'package:paperless_mobile/routes/typed/top_level/settings_route.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
|
||||
import 'package:url_launcher/url_launcher_string.dart';
|
||||
|
||||
class AppDrawer extends StatelessWidget {
|
||||
@@ -21,6 +24,7 @@ class AppDrawer extends StatelessWidget {
|
||||
return SafeArea(
|
||||
child: Drawer(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
@@ -60,56 +64,126 @@ class AppDrawer extends StatelessWidget {
|
||||
),
|
||||
ListTile(
|
||||
dense: true,
|
||||
leading: Padding(
|
||||
padding: const EdgeInsets.only(left: 3),
|
||||
child: SvgPicture.asset(
|
||||
'assets/images/bmc-logo.svg',
|
||||
width: 24,
|
||||
height: 24,
|
||||
),
|
||||
leading: const Icon(Icons.favorite_outline),
|
||||
title: Text(S.of(context)!.donate),
|
||||
onTap: () {
|
||||
showDialog(
|
||||
context: context,
|
||||
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(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
Text(S.of(context)!.donateCoffee),
|
||||
const Icon(
|
||||
Icons.open_in_new,
|
||||
size: 16,
|
||||
)
|
||||
],
|
||||
title: Text(S.of(context)!.sourceCode),
|
||||
trailing: const Icon(
|
||||
Icons.open_in_new,
|
||||
size: 16,
|
||||
),
|
||||
onTap: () {
|
||||
launchUrlString(
|
||||
"https://www.buymeacoffee.com/astubenbord",
|
||||
"https://github.com/astubenbord/paperless-mobile",
|
||||
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(
|
||||
dense: true,
|
||||
leading: const Icon(Icons.settings_outlined),
|
||||
title: Text(
|
||||
S.of(context)!.settings,
|
||||
),
|
||||
onTap: () => Navigator.of(context).push(
|
||||
MaterialPageRoute(
|
||||
builder: (_) => MultiProvider(
|
||||
providers: [
|
||||
Provider.value(
|
||||
value: context.read<PaperlessServerStatsApi>()),
|
||||
Provider.value(value: context.read<ApiVersion>()),
|
||||
],
|
||||
child: const SettingsPage(),
|
||||
),
|
||||
),
|
||||
),
|
||||
onTap: () => SettingsRoute().push(context),
|
||||
),
|
||||
const Divider(),
|
||||
Text(
|
||||
S.of(context)!.views,
|
||||
textAlign: TextAlign.left,
|
||||
style: Theme.of(context).textTheme.labelLarge,
|
||||
).padded(16),
|
||||
_buildSavedViews(),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildSavedViews() {
|
||||
return BlocBuilder<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) {
|
||||
final theme = Theme.of(context);
|
||||
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:flutter/material.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
import 'package:paperless_api/paperless_api.dart';
|
||||
import 'package:paperless_mobile/core/widgets/form_fields/fullscreen_selection_form.dart';
|
||||
import 'package:paperless_mobile/extensions/dart_extensions.dart';
|
||||
@@ -86,6 +87,7 @@ class _FullscreenBulkEditLabelPageState<T extends Label>
|
||||
selectionCount: _labels.length,
|
||||
floatingActionButton: !hideFab
|
||||
? FloatingActionButton.extended(
|
||||
heroTag: "fab_fullscreen_bulk_edit_label",
|
||||
onPressed: _onSubmit,
|
||||
label: Text(S.of(context)!.apply),
|
||||
icon: const Icon(Icons.done),
|
||||
@@ -122,7 +124,7 @@ class _FullscreenBulkEditLabelPageState<T extends Label>
|
||||
|
||||
void _onSubmit() async {
|
||||
if (_selection == null) {
|
||||
Navigator.pop(context);
|
||||
context.pop();
|
||||
} else {
|
||||
bool shouldPerformAction;
|
||||
if (_selection!.label == null) {
|
||||
@@ -148,7 +150,7 @@ class _FullscreenBulkEditLabelPageState<T extends Label>
|
||||
}
|
||||
if (shouldPerformAction) {
|
||||
widget.onSubmit(_selection!.label);
|
||||
Navigator.pop(context);
|
||||
context.pop();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+3
-1
@@ -1,6 +1,7 @@
|
||||
import 'package:collection/collection.dart';
|
||||
import 'package:flutter/material.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/core/widgets/form_fields/fullscreen_selection_form.dart';
|
||||
import 'package:paperless_mobile/extensions/dart_extensions.dart';
|
||||
@@ -74,6 +75,7 @@ class _FullscreenBulkEditTagsWidgetState
|
||||
controller: _controller,
|
||||
floatingActionButton: _addTags.isNotEmpty || _removeTags.isNotEmpty
|
||||
? FloatingActionButton.extended(
|
||||
heroTag: "fab_fullscreen_bulk_edit_tags",
|
||||
label: Text(S.of(context)!.apply),
|
||||
icon: const Icon(Icons.done),
|
||||
onPressed: _submit,
|
||||
@@ -173,7 +175,7 @@ class _FullscreenBulkEditTagsWidgetState
|
||||
removeTagIds: _removeTags,
|
||||
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_mobile/core/notifier/document_changed_notifier.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/features/notifications/services/local_notification_service.dart';
|
||||
import 'package:printing/printing.dart';
|
||||
import 'package:share_plus/share_plus.dart';
|
||||
import 'package:cross_file/cross_file.dart';
|
||||
|
||||
import 'package:path/path.dart' as p;
|
||||
part 'document_details_cubit.freezed.dart';
|
||||
part 'document_details_state.dart';
|
||||
|
||||
@@ -45,7 +44,6 @@ class DocumentDetailsCubit extends Cubit<DocumentDetailsState> {
|
||||
),
|
||||
),
|
||||
);
|
||||
loadSuggestions();
|
||||
loadMetaData();
|
||||
}
|
||||
|
||||
@@ -54,13 +52,6 @@ class DocumentDetailsCubit extends Cubit<DocumentDetailsState> {
|
||||
_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 {
|
||||
final metaData = await _api.getMetaData(state.document);
|
||||
if (!isClosed) {
|
||||
@@ -101,11 +92,9 @@ class DocumentDetailsCubit extends Cubit<DocumentDetailsState> {
|
||||
if (state.metaData == null) {
|
||||
await loadMetaData();
|
||||
}
|
||||
final desc = FileDescription.fromPath(
|
||||
state.metaData!.mediaFilename.replaceAll("/", " "),
|
||||
);
|
||||
final filePath = state.metaData!.mediaFilename.replaceAll("/", " ");
|
||||
|
||||
final fileName = "${desc.filename}.pdf";
|
||||
final fileName = "${p.basenameWithoutExtension(filePath)}.pdf";
|
||||
final file = File("${cacheDir.path}/$fileName");
|
||||
|
||||
if (!file.existsSync()) {
|
||||
@@ -128,51 +117,63 @@ class DocumentDetailsCubit extends Cubit<DocumentDetailsState> {
|
||||
Future<void> downloadDocument({
|
||||
bool downloadOriginal = false,
|
||||
required String locale,
|
||||
required String userId,
|
||||
}) async {
|
||||
if (state.metaData == null) {
|
||||
await loadMetaData();
|
||||
}
|
||||
String filePath = _buildDownloadFilePath(
|
||||
String targetPath = _buildDownloadFilePath(
|
||||
downloadOriginal,
|
||||
await FileService.downloadsDirectory,
|
||||
);
|
||||
final desc = FileDescription.fromPath(
|
||||
state.metaData!.mediaFilename
|
||||
.replaceAll("/", " "), // Flatten directory structure
|
||||
);
|
||||
if (!File(filePath).existsSync()) {
|
||||
File(filePath).createSync();
|
||||
|
||||
if (!await File(targetPath).exists()) {
|
||||
await File(targetPath).create();
|
||||
} else {
|
||||
return _notificationService.notifyFileDownload(
|
||||
await _notificationService.notifyFileDownload(
|
||||
document: state.document,
|
||||
filename: "${desc.filename}.${desc.extension}",
|
||||
filePath: filePath,
|
||||
filename: p.basename(targetPath),
|
||||
filePath: targetPath,
|
||||
finished: true,
|
||||
locale: locale,
|
||||
userId: userId,
|
||||
);
|
||||
}
|
||||
|
||||
await _notificationService.notifyFileDownload(
|
||||
document: state.document,
|
||||
filename: "${desc.filename}.${desc.extension}",
|
||||
filePath: filePath,
|
||||
finished: false,
|
||||
locale: locale,
|
||||
);
|
||||
// await _notificationService.notifyFileDownload(
|
||||
// document: state.document,
|
||||
// filename: p.basename(targetPath),
|
||||
// filePath: targetPath,
|
||||
// finished: false,
|
||||
// locale: locale,
|
||||
// userId: userId,
|
||||
// );
|
||||
|
||||
await _api.downloadToFile(
|
||||
state.document,
|
||||
filePath,
|
||||
targetPath,
|
||||
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(
|
||||
document: state.document,
|
||||
filename: "${desc.filename}.${desc.extension}",
|
||||
filePath: filePath,
|
||||
filename: p.basename(targetPath),
|
||||
filePath: targetPath,
|
||||
finished: true,
|
||||
locale: locale,
|
||||
userId: userId,
|
||||
);
|
||||
debugPrint("Downloaded file to $filePath");
|
||||
debugPrint("Downloaded file to $targetPath");
|
||||
}
|
||||
|
||||
Future<void> shareDocument({bool shareOriginal = false}) async {
|
||||
@@ -223,12 +224,9 @@ class DocumentDetailsCubit extends Cubit<DocumentDetailsState> {
|
||||
}
|
||||
|
||||
String _buildDownloadFilePath(bool original, Directory dir) {
|
||||
final description = FileDescription.fromPath(
|
||||
state.metaData!.mediaFilename
|
||||
.replaceAll("/", " "), // Flatten directory structure
|
||||
);
|
||||
final extension = original ? description.extension : 'pdf';
|
||||
return "${dir.path}/${description.filename}.$extension";
|
||||
final normalizedPath = state.metaData!.mediaFilename.replaceAll("/", " ");
|
||||
final extension = original ? p.extension(normalizedPath) : '.pdf';
|
||||
return "${dir.path}/${p.basenameWithoutExtension(normalizedPath)}$extension";
|
||||
}
|
||||
|
||||
@override
|
||||
|
||||
@@ -7,7 +7,6 @@ class DocumentDetailsState with _$DocumentDetailsState {
|
||||
DocumentMetaData? metaData,
|
||||
@Default(false) bool isFullContentLoaded,
|
||||
String? fullContent,
|
||||
FieldSuggestions? suggestions,
|
||||
@Default({}) Map<int, Correspondent> correspondents,
|
||||
@Default({}) Map<int, DocumentType> documentTypes,
|
||||
@Default({}) Map<int, Tag> tags,
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
import 'package:open_filex/open_filex.dart';
|
||||
import 'package:paperless_api/paperless_api.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_permissions_widget.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/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/view/similar_documents_view.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/routes/typed/branches/documents_route.dart';
|
||||
|
||||
class DocumentDetailsPage extends StatefulWidget {
|
||||
final bool isLabelClickable;
|
||||
@@ -46,9 +45,9 @@ class _DocumentDetailsPageState extends State<DocumentDetailsPage> {
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final apiVersion = context.watch<ApiVersion>();
|
||||
|
||||
final tabLength = 4 + (apiVersion.hasMultiUserSupport ? 1 : 0);
|
||||
final hasMultiUserSupport =
|
||||
context.watch<LocalUserAccount>().hasMultiUserSupport;
|
||||
final tabLength = 4 + (hasMultiUserSupport && false ? 1 : 0);
|
||||
return WillPopScope(
|
||||
onWillPop: () async {
|
||||
Navigator.of(context)
|
||||
@@ -86,45 +85,52 @@ class _DocumentDetailsPageState extends State<DocumentDetailsPage> {
|
||||
collapsedHeight: kToolbarHeight,
|
||||
expandedHeight: 250.0,
|
||||
flexibleSpace: FlexibleSpaceBar(
|
||||
background: Stack(
|
||||
alignment: Alignment.topCenter,
|
||||
children: [
|
||||
BlocBuilder<DocumentDetailsCubit,
|
||||
DocumentDetailsState>(
|
||||
builder: (context, state) {
|
||||
return Positioned.fill(
|
||||
child: DocumentPreview(
|
||||
document: state.document,
|
||||
fit: BoxFit.cover,
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
Positioned.fill(
|
||||
top: 0,
|
||||
child: DecoratedBox(
|
||||
decoration: BoxDecoration(
|
||||
gradient: LinearGradient(
|
||||
colors: [
|
||||
Theme.of(context)
|
||||
.colorScheme
|
||||
.background
|
||||
.withOpacity(0.8),
|
||||
Theme.of(context)
|
||||
.colorScheme
|
||||
.background
|
||||
.withOpacity(0.5),
|
||||
Colors.transparent,
|
||||
Colors.transparent,
|
||||
Colors.transparent,
|
||||
],
|
||||
begin: Alignment.topCenter,
|
||||
end: Alignment.bottomCenter,
|
||||
),
|
||||
background: BlocBuilder<DocumentDetailsCubit,
|
||||
DocumentDetailsState>(
|
||||
builder: (context, state) {
|
||||
return Hero(
|
||||
tag: "thumb_${state.document.id}",
|
||||
child: GestureDetector(
|
||||
onTap: () {
|
||||
DocumentPreviewRoute($extra: state.document)
|
||||
.push(context);
|
||||
},
|
||||
child: Stack(
|
||||
alignment: Alignment.topCenter,
|
||||
children: [
|
||||
Positioned.fill(
|
||||
child: DocumentPreview(
|
||||
enableHero: false,
|
||||
document: state.document,
|
||||
fit: BoxFit.cover,
|
||||
),
|
||||
),
|
||||
Positioned.fill(
|
||||
child: DecoratedBox(
|
||||
decoration: BoxDecoration(
|
||||
gradient: LinearGradient(
|
||||
stops: [0.2, 0.4],
|
||||
colors: [
|
||||
Theme.of(context)
|
||||
.colorScheme
|
||||
.background
|
||||
.withOpacity(0.6),
|
||||
Theme.of(context)
|
||||
.colorScheme
|
||||
.background
|
||||
.withOpacity(0.3),
|
||||
],
|
||||
begin: Alignment.topCenter,
|
||||
end: Alignment.bottomCenter,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
bottom: ColoredTabBar(
|
||||
@@ -171,7 +177,7 @@ class _DocumentDetailsPageState extends State<DocumentDetailsPage> {
|
||||
),
|
||||
),
|
||||
),
|
||||
if (apiVersion.hasMultiUserSupport)
|
||||
if (hasMultiUserSupport && false)
|
||||
Tab(
|
||||
child: Text(
|
||||
"Permissions",
|
||||
@@ -195,6 +201,7 @@ class _DocumentDetailsPageState extends State<DocumentDetailsPage> {
|
||||
context.read(),
|
||||
context.read(),
|
||||
context.read(),
|
||||
context.read(),
|
||||
documentId: state.document.id,
|
||||
),
|
||||
child: Padding(
|
||||
@@ -259,7 +266,7 @@ class _DocumentDetailsPageState extends State<DocumentDetailsPage> {
|
||||
),
|
||||
],
|
||||
),
|
||||
if (apiVersion.hasMultiUserSupport)
|
||||
if (hasMultiUserSupport && false)
|
||||
CustomScrollView(
|
||||
controller: _pagingScrollController,
|
||||
slivers: [
|
||||
@@ -286,8 +293,10 @@ class _DocumentDetailsPageState extends State<DocumentDetailsPage> {
|
||||
}
|
||||
|
||||
Widget _buildEditButton() {
|
||||
final currentUser = context.watch<LocalUserAccount>();
|
||||
|
||||
bool canEdit = context.watchInternetConnection &&
|
||||
LocalUserAccount.current.paperlessUser.canEditDocuments;
|
||||
currentUser.paperlessUser.canEditDocuments;
|
||||
if (!canEdit) {
|
||||
return const SizedBox.shrink();
|
||||
}
|
||||
@@ -301,8 +310,9 @@ class _DocumentDetailsPageState extends State<DocumentDetailsPage> {
|
||||
preferBelow: false,
|
||||
verticalOffset: 40,
|
||||
child: FloatingActionButton(
|
||||
heroTag: "fab_document_details",
|
||||
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(
|
||||
child: BlocBuilder<ConnectivityCubit, ConnectivityState>(
|
||||
builder: (context, connectivityState) {
|
||||
final isConnected = connectivityState.isConnected;
|
||||
|
||||
final canDelete = isConnected &&
|
||||
LocalUserAccount.current.paperlessUser.canDeleteDocuments;
|
||||
final currentUser = context.watch<LocalUserAccount>();
|
||||
return Row(
|
||||
mainAxisAlignment: MainAxisAlignment.start,
|
||||
children: [
|
||||
IconButton(
|
||||
tooltip: S.of(context)!.deleteDocumentTooltip,
|
||||
icon: const Icon(Icons.delete),
|
||||
onPressed:
|
||||
canDelete ? () => _onDelete(state.document) : null,
|
||||
).paddedSymmetrically(horizontal: 4),
|
||||
DocumentDownloadButton(
|
||||
document: state.document,
|
||||
enabled: isConnected,
|
||||
ConnectivityAwareActionWrapper(
|
||||
disabled: !currentUser.paperlessUser.canDeleteDocuments,
|
||||
offlineBuilder: (context, child) {
|
||||
return const IconButton(
|
||||
icon: Icon(Icons.delete),
|
||||
onPressed: null,
|
||||
).paddedSymmetrically(horizontal: 4);
|
||||
},
|
||||
child: IconButton(
|
||||
tooltip: S.of(context)!.deleteDocumentTooltip,
|
||||
icon: const Icon(Icons.delete),
|
||||
onPressed: () => _onDelete(state.document),
|
||||
).paddedSymmetrically(horizontal: 4),
|
||||
),
|
||||
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),
|
||||
IconButton(
|
||||
tooltip: S.of(context)!.print, //TODO: INTL
|
||||
tooltip: S.of(context)!.print,
|
||||
onPressed: () =>
|
||||
context.read<DocumentDetailsCubit>().printDocument(),
|
||||
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 {
|
||||
final status =
|
||||
await context.read<DocumentDetailsCubit>().openDocumentInSystemViewer();
|
||||
@@ -427,25 +406,21 @@ class _DocumentDetailsPageState extends State<DocumentDetailsPage> {
|
||||
if (delete) {
|
||||
try {
|
||||
await context.read<DocumentDetailsCubit>().delete(document);
|
||||
showSnackBar(context, S.of(context)!.documentSuccessfullyDeleted);
|
||||
// showSnackBar(context, S.of(context)!.documentSuccessfullyDeleted);
|
||||
} on PaperlessApiException catch (error, stackTrace) {
|
||||
showErrorMessage(context, error, stackTrace);
|
||||
} finally {
|
||||
// Document deleted => go back to primary route
|
||||
Navigator.popUntil(context, (route) => route.isFirst);
|
||||
do {
|
||||
context.pop();
|
||||
} while (context.canPop());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _onOpen(DocumentModel document) async {
|
||||
Navigator.of(context).push(
|
||||
MaterialPageRoute(
|
||||
builder: (_) => DocumentView(
|
||||
documentBytes:
|
||||
context.read<PaperlessDocumentsApi>().download(document),
|
||||
title: document.title,
|
||||
),
|
||||
),
|
||||
);
|
||||
DocumentPreviewRoute(
|
||||
$extra: document,
|
||||
title: document.title,
|
||||
).push(context);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -47,7 +47,7 @@ class _ArchiveSerialNumberFieldState extends State<ArchiveSerialNumberField> {
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final userCanEditDocument =
|
||||
LocalUserAccount.current.paperlessUser.canEditDocuments;
|
||||
context.watch<LocalUserAccount>().paperlessUser.canEditDocuments;
|
||||
return BlocListener<DocumentDetailsCubit, DocumentDetailsState>(
|
||||
listenWhen: (previous, current) =>
|
||||
previous.document.archiveSerialNumber !=
|
||||
|
||||
@@ -4,6 +4,7 @@ import 'package:flutter/material.dart';
|
||||
import 'package:hive/hive.dart';
|
||||
import 'package:paperless_api/paperless_api.dart';
|
||||
import 'package:paperless_mobile/core/config/hive/hive_config.dart';
|
||||
import 'package:paperless_mobile/core/database/tables/local_user_account.dart';
|
||||
import 'package:paperless_mobile/extensions/flutter_extensions.dart';
|
||||
import 'package:paperless_mobile/features/document_details/cubit/document_details_cubit.dart';
|
||||
import 'package:paperless_mobile/features/document_details/view/dialogs/select_file_type_dialog.dart';
|
||||
@@ -90,9 +91,11 @@ class _DocumentDownloadButtonState extends State<DocumentDownloadButton> {
|
||||
}
|
||||
|
||||
setState(() => _isDownloadPending = true);
|
||||
final userId = context.read<LocalUserAccount>().id;
|
||||
await context.read<DocumentDetailsCubit>().downloadDocument(
|
||||
downloadOriginal: original,
|
||||
locale: globalSettings.preferredLocaleSubtag,
|
||||
userId: userId,
|
||||
);
|
||||
// showSnackBar(context, S.of(context)!.documentSuccessfullyDownloaded);
|
||||
} on PaperlessApiException catch (error, stackTrace) {
|
||||
|
||||
@@ -2,6 +2,7 @@ import 'package:flutter/material.dart';
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
import 'package:intl/intl.dart';
|
||||
import 'package:paperless_api/paperless_api.dart';
|
||||
import 'package:paperless_mobile/core/database/tables/local_user_account.dart';
|
||||
import 'package:paperless_mobile/extensions/flutter_extensions.dart';
|
||||
import 'package:paperless_mobile/features/document_details/cubit/document_details_cubit.dart';
|
||||
import 'package:paperless_mobile/features/document_details/view/widgets/archive_serial_number_field.dart';
|
||||
@@ -25,6 +26,7 @@ class DocumentMetaDataWidget extends StatefulWidget {
|
||||
class _DocumentMetaDataWidgetState extends State<DocumentMetaDataWidget> {
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final currentUser = context.watch<LocalUserAccount>().paperlessUser;
|
||||
return BlocBuilder<DocumentDetailsCubit, DocumentDetailsState>(
|
||||
builder: (context, state) {
|
||||
if (state.metaData == null) {
|
||||
@@ -37,9 +39,10 @@ class _DocumentMetaDataWidgetState extends State<DocumentMetaDataWidget> {
|
||||
return SliverList(
|
||||
delegate: SliverChildListDelegate(
|
||||
[
|
||||
ArchiveSerialNumberField(
|
||||
document: widget.document,
|
||||
).paddedOnly(bottom: widget.itemSpacing),
|
||||
if (currentUser.canEditDocuments)
|
||||
ArchiveSerialNumberField(
|
||||
document: widget.document,
|
||||
).paddedOnly(bottom: widget.itemSpacing),
|
||||
DetailsItem.text(
|
||||
DateFormat().format(widget.document.modified),
|
||||
context: context,
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
import 'package:intl/intl.dart';
|
||||
import 'package:paperless_api/paperless_api.dart';
|
||||
import 'package:paperless_mobile/core/database/tables/local_user_account.dart';
|
||||
@@ -30,62 +31,66 @@ class DocumentOverviewWidget extends StatelessWidget {
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return SliverList(
|
||||
delegate: SliverChildListDelegate(
|
||||
[
|
||||
return SliverList.list(
|
||||
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(
|
||||
label: S.of(context)!.title,
|
||||
content: HighlightedText(
|
||||
text: document.title,
|
||||
highlights: queryString?.split(" ") ?? [],
|
||||
label: S.of(context)!.documentType,
|
||||
content: LabelText<DocumentType>(
|
||||
style: Theme.of(context).textTheme.bodyLarge,
|
||||
label: availableDocumentTypes[document.documentType],
|
||||
),
|
||||
).paddedOnly(bottom: itemSpacing),
|
||||
DetailsItem.text(
|
||||
DateFormat.yMMMMd().format(document.created),
|
||||
context: context,
|
||||
label: S.of(context)!.createdAt,
|
||||
if (document.correspondent != null &&
|
||||
context
|
||||
.watch<LocalUserAccount>()
|
||||
.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.documentType != null &&
|
||||
LocalUserAccount.current.paperlessUser.canViewDocumentTypes)
|
||||
DetailsItem(
|
||||
label: S.of(context)!.documentType,
|
||||
content: LabelText<DocumentType>(
|
||||
style: Theme.of(context).textTheme.bodyLarge,
|
||||
label: availableDocumentTypes[document.documentType],
|
||||
if (document.storagePath != null &&
|
||||
context.watch<LocalUserAccount>().paperlessUser.canViewStoragePaths)
|
||||
DetailsItem(
|
||||
label: S.of(context)!.storagePath,
|
||||
content: LabelText<StoragePath>(
|
||||
label: availableStoragePaths[document.storagePath],
|
||||
),
|
||||
).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 &&
|
||||
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),
|
||||
],
|
||||
),
|
||||
),
|
||||
).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/settings/model/file_download_type.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/permission_helpers.dart';
|
||||
import 'package:permission_handler/permission_handler.dart';
|
||||
@@ -34,19 +35,25 @@ class _DocumentShareButtonState extends State<DocumentShareButton> {
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return IconButton(
|
||||
tooltip: S.of(context)!.shareTooltip,
|
||||
icon: _isDownloadPending
|
||||
? const SizedBox(
|
||||
height: 16,
|
||||
width: 16,
|
||||
child: CircularProgressIndicator(),
|
||||
)
|
||||
: const Icon(Icons.share),
|
||||
onPressed: widget.document != null && widget.enabled
|
||||
? () => _onShare(widget.document!)
|
||||
: null,
|
||||
).paddedOnly(right: 4);
|
||||
return ConnectivityAwareActionWrapper(
|
||||
offlineBuilder: (context, child) => const IconButton(
|
||||
icon: Icon(Icons.share),
|
||||
onPressed: null,
|
||||
),
|
||||
child: IconButton(
|
||||
tooltip: S.of(context)!.shareTooltip,
|
||||
icon: _isDownloadPending
|
||||
? const SizedBox(
|
||||
height: 16,
|
||||
width: 16,
|
||||
child: CircularProgressIndicator(),
|
||||
)
|
||||
: const Icon(Icons.share),
|
||||
onPressed: widget.document != null && widget.enabled
|
||||
? () => _onShare(widget.document!)
|
||||
: null,
|
||||
).paddedOnly(right: 4),
|
||||
);
|
||||
}
|
||||
|
||||
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) {
|
||||
emit(state.copyWith(document: document));
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@ part of 'document_edit_cubit.dart';
|
||||
class DocumentEditState with _$DocumentEditState {
|
||||
const factory DocumentEditState({
|
||||
required DocumentModel document,
|
||||
FieldSuggestions? suggestions,
|
||||
@Default({}) Map<int, Correspondent> correspondents,
|
||||
@Default({}) Map<int, DocumentType> documentTypes,
|
||||
@Default({}) Map<int, StoragePath> storagePaths,
|
||||
|
||||
@@ -4,11 +4,12 @@ import 'package:flutter/material.dart';
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
import 'package:flutter_form_builder/flutter_form_builder.dart';
|
||||
import 'package:flutter_typeahead/flutter_typeahead.dart';
|
||||
|
||||
import 'package:go_router/go_router.dart';
|
||||
import 'package:intl/intl.dart';
|
||||
import 'package:paperless_api/paperless_api.dart';
|
||||
import 'package:paperless_mobile/core/database/tables/local_user_account.dart';
|
||||
import 'package:paperless_mobile/core/repository/label_repository.dart';
|
||||
import 'package:paperless_mobile/core/widgets/dialog_utils/pop_with_unsaved_changes.dart';
|
||||
import 'package:paperless_mobile/core/workarounds/colored_chip.dart';
|
||||
import 'package:paperless_mobile/extensions/flutter_extensions.dart';
|
||||
import 'package:paperless_mobile/features/document_edit/cubit/document_edit_cubit.dart';
|
||||
@@ -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/view/widgets/label_form_field.dart';
|
||||
import 'package:paperless_mobile/generated/l10n/app_localizations.dart';
|
||||
|
||||
import 'package:paperless_mobile/helpers/message_helpers.dart';
|
||||
|
||||
class DocumentEditPage extends StatefulWidget {
|
||||
final FieldSuggestions? suggestions;
|
||||
const DocumentEditPage({
|
||||
Key? key,
|
||||
required this.suggestions,
|
||||
}) : super(key: key);
|
||||
|
||||
@override
|
||||
@@ -42,256 +40,261 @@ class _DocumentEditPageState extends State<DocumentEditPage> {
|
||||
static const fkContent = 'content';
|
||||
|
||||
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
|
||||
Widget build(BuildContext context) {
|
||||
return BlocBuilder<DocumentEditCubit, DocumentEditState>(
|
||||
builder: (context, state) {
|
||||
return DefaultTabController(
|
||||
length: 2,
|
||||
child: Scaffold(
|
||||
resizeToAvoidBottomInset: false,
|
||||
floatingActionButton: FloatingActionButton.extended(
|
||||
onPressed: () => _onSubmit(state.document),
|
||||
icon: const Icon(Icons.save),
|
||||
label: Text(S.of(context)!.saveChanges),
|
||||
),
|
||||
appBar: AppBar(
|
||||
title: Text(S.of(context)!.editDocument),
|
||||
bottom: TabBar(
|
||||
tabs: [
|
||||
Tab(
|
||||
text: S.of(context)!.overview,
|
||||
),
|
||||
Tab(
|
||||
text: S.of(context)!.content,
|
||||
)
|
||||
],
|
||||
final currentUser = context.watch<LocalUserAccount>().paperlessUser;
|
||||
return PopWithUnsavedChanges(
|
||||
hasChangesPredicate: () => _formKey.currentState?.isDirty ?? false,
|
||||
child: BlocBuilder<DocumentEditCubit, DocumentEditState>(
|
||||
builder: (context, state) {
|
||||
final filteredSuggestions = state.suggestions?.documentDifference(
|
||||
context.read<DocumentEditCubit>().state.document);
|
||||
return DefaultTabController(
|
||||
length: 2,
|
||||
child: Scaffold(
|
||||
resizeToAvoidBottomInset: false,
|
||||
floatingActionButton: FloatingActionButton.extended(
|
||||
heroTag: "fab_document_edit",
|
||||
onPressed: () => _onSubmit(state.document),
|
||||
icon: const Icon(Icons.save),
|
||||
label: Text(S.of(context)!.saveChanges),
|
||||
),
|
||||
),
|
||||
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)
|
||||
.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),
|
||||
],
|
||||
),
|
||||
),
|
||||
appBar: AppBar(
|
||||
title: Text(S.of(context)!.editDocument),
|
||||
bottom: TabBar(
|
||||
tabs: [
|
||||
Tab(text: S.of(context)!.overview),
|
||||
Tab(text: S.of(context)!.content)
|
||||
],
|
||||
),
|
||||
),
|
||||
)),
|
||||
);
|
||||
},
|
||||
extendBody: true,
|
||||
body: Padding(
|
||||
padding: EdgeInsets.only(
|
||||
bottom: MediaQuery.of(context).viewInsets.bottom,
|
||||
top: 8,
|
||||
left: 8,
|
||||
right: 8,
|
||||
),
|
||||
child: FormBuilder(
|
||||
key: _formKey,
|
||||
child: TabBarView(
|
||||
children: [
|
||||
ListView(
|
||||
children: [
|
||||
_buildTitleFormField(state.document.title).padded(),
|
||||
_buildCreatedAtFormField(
|
||||
state.document.created,
|
||||
filteredSuggestions,
|
||||
).padded(),
|
||||
// Correspondent form field
|
||||
if (currentUser.canViewCorrespondents)
|
||||
Column(
|
||||
children: [
|
||||
LabelFormField<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(
|
||||
title: values[fkTitle],
|
||||
created: values[fkCreatedDate],
|
||||
documentType: () => (values[fkDocumentType] as IdQueryParameter)
|
||||
.whenOrNull(fromId: (id) => id),
|
||||
correspondent: () => (values[fkCorrespondent] as IdQueryParameter)
|
||||
.whenOrNull(fromId: (id) => id),
|
||||
storagePath: () => (values[fkStoragePath] as IdQueryParameter)
|
||||
.whenOrNull(fromId: (id) => id),
|
||||
tags: (values[fkTags] as IdsTagsQuery).include,
|
||||
documentType: () => (values[fkDocumentType] as IdQueryParameter?)
|
||||
?.whenOrNull(fromId: (id) => id),
|
||||
correspondent: () => (values[fkCorrespondent] as IdQueryParameter?)
|
||||
?.whenOrNull(fromId: (id) => id),
|
||||
storagePath: () => (values[fkStoragePath] as IdQueryParameter?)
|
||||
?.whenOrNull(fromId: (id) => id),
|
||||
tags: (values[fkTags] as IdsTagsQuery?)?.include,
|
||||
content: values[fkContent],
|
||||
);
|
||||
setState(() {
|
||||
_isSubmitLoading = true;
|
||||
});
|
||||
|
||||
try {
|
||||
await context.read<DocumentEditCubit>().updateDocument(mergedDocument);
|
||||
showSnackBar(context, S.of(context)!.documentSuccessfullyUpdated);
|
||||
} on PaperlessApiException catch (error, stackTrace) {
|
||||
showErrorMessage(context, error, stackTrace);
|
||||
} finally {
|
||||
setState(() {
|
||||
_isSubmitLoading = false;
|
||||
});
|
||||
Navigator.pop(context);
|
||||
context.pop();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -343,7 +341,8 @@ class _DocumentEditPageState extends State<DocumentEditPage> {
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildCreatedAtFormField(DateTime? initialCreatedAtDate) {
|
||||
Widget _buildCreatedAtFormField(
|
||||
DateTime? initialCreatedAtDate, FieldSuggestions? filteredSuggestions) {
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
@@ -358,9 +357,9 @@ class _DocumentEditPageState extends State<DocumentEditPage> {
|
||||
format: DateFormat.yMMMMd(),
|
||||
initialEntryMode: DatePickerEntryMode.calendar,
|
||||
),
|
||||
if (_filteredSuggestions?.hasSuggestedDates ?? false)
|
||||
if (filteredSuggestions?.hasSuggestedDates ?? false)
|
||||
_buildSuggestionsSkeleton<DateTime>(
|
||||
suggestions: _filteredSuggestions!.dates,
|
||||
suggestions: filteredSuggestions!.dates,
|
||||
itemBuilder: (context, itemData) => ActionChip(
|
||||
label: Text(DateFormat.yMMMd().format(itemData)),
|
||||
onPressed: () => _formKey.currentState?.fields[fkCreatedDate]
|
||||
|
||||
@@ -1,43 +1,71 @@
|
||||
import 'dart:developer';
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
import 'package:paperless_api/paperless_api.dart';
|
||||
import 'package:paperless_mobile/core/model/info_message_exception.dart';
|
||||
import 'package:paperless_mobile/core/service/file_service.dart';
|
||||
import 'package:paperless_mobile/features/notifications/services/local_notification_service.dart';
|
||||
import 'package:rxdart/rxdart.dart';
|
||||
|
||||
class DocumentScannerCubit extends Cubit<List<File>> {
|
||||
part 'document_scanner_state.dart';
|
||||
|
||||
class DocumentScannerCubit extends Cubit<DocumentScannerState> {
|
||||
final LocalNotificationService _notificationService;
|
||||
|
||||
DocumentScannerCubit(this._notificationService) : super(const []);
|
||||
DocumentScannerCubit(this._notificationService)
|
||||
: super(const InitialDocumentScannerState());
|
||||
|
||||
void addScan(File file) => emit([...state, file]);
|
||||
|
||||
void removeScan(int fileIndex) {
|
||||
try {
|
||||
state[fileIndex].deleteSync();
|
||||
final scans = [...state];
|
||||
scans.removeAt(fileIndex);
|
||||
emit(scans);
|
||||
} catch (_) {
|
||||
throw const PaperlessApiException(ErrorCode.scanRemoveFailed);
|
||||
}
|
||||
Future<void> initialize() async {
|
||||
debugPrint("Restoring scans...");
|
||||
emit(const RestoringDocumentScannerState());
|
||||
final tempDir = await FileService.temporaryScansDirectory;
|
||||
final allFiles = tempDir.list().whereType<File>();
|
||||
final scans =
|
||||
await allFiles.where((event) => event.path.endsWith(".jpeg")).toList();
|
||||
debugPrint("Restored ${scans.length} scans.");
|
||||
emit(
|
||||
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 {
|
||||
for (final doc in state) {
|
||||
doc.deleteSync();
|
||||
if (kDebugMode) {
|
||||
log('[ScannerCubit]: Removed ${doc.path}');
|
||||
}
|
||||
}
|
||||
await file.delete();
|
||||
} catch (error, stackTrace) {
|
||||
throw InfoMessageException(
|
||||
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();
|
||||
emit([]);
|
||||
} catch (_) {
|
||||
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));
|
||||
}
|
||||
@@ -10,23 +10,22 @@ import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
import 'package:hive/hive.dart';
|
||||
import 'package:paperless_api/paperless_api.dart';
|
||||
import 'package:paperless_mobile/constants.dart';
|
||||
import 'package:paperless_mobile/core/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/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/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/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_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/tasks/cubit/task_status_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/helpers/message_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:pdf/pdf.dart';
|
||||
import 'package:pdf/widgets.dart' as pw;
|
||||
@@ -51,71 +50,54 @@ class _ScannerPageState extends State<ScannerPage>
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return BlocBuilder<ConnectivityCubit, ConnectivityState>(
|
||||
builder: (context, connectedState) {
|
||||
return Scaffold(
|
||||
drawer: const AppDrawer(),
|
||||
floatingActionButton: FloatingActionButton(
|
||||
onPressed: () => _openDocumentScanner(context),
|
||||
child: const Icon(Icons.add_a_photo_outlined),
|
||||
),
|
||||
body: BlocBuilder<DocumentScannerCubit, List<File>>(
|
||||
return SafeArea(
|
||||
top: true,
|
||||
child: Scaffold(
|
||||
drawer: const AppDrawer(),
|
||||
floatingActionButton: FloatingActionButton(
|
||||
heroTag: "fab_document_edit",
|
||||
onPressed: () => _openDocumentScanner(context),
|
||||
child: const Icon(Icons.add_a_photo_outlined),
|
||||
),
|
||||
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) {
|
||||
return SafeArea(
|
||||
child: Scaffold(
|
||||
drawer: const AppDrawer(),
|
||||
floatingActionButton: FloatingActionButton(
|
||||
onPressed: () => _openDocumentScanner(context),
|
||||
child: const Icon(Icons.add_a_photo_outlined),
|
||||
return switch (state) {
|
||||
InitialDocumentScannerState() => _buildEmptyState(),
|
||||
RestoringDocumentScannerState() => Center(
|
||||
child: Text("Restoring..."),
|
||||
),
|
||||
body: NestedScrollView(
|
||||
floatHeaderSlivers: true,
|
||||
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);
|
||||
}
|
||||
},
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
LoadedDocumentScannerState() => _buildImageGrid(state.scans),
|
||||
ErrorDocumentScannerState() => Placeholder(),
|
||||
};
|
||||
},
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildActions(bool isConnected) {
|
||||
Widget _buildActions() {
|
||||
return ColoredBox(
|
||||
color: Theme.of(context).colorScheme.background,
|
||||
child: SizedBox(
|
||||
height: kTextTabBarHeight,
|
||||
child: BlocBuilder<DocumentScannerCubit, List<File>>(
|
||||
child: BlocBuilder<DocumentScannerCubit, DocumentScannerState>(
|
||||
builder: (context, state) {
|
||||
return RawScrollbar(
|
||||
padding: EdgeInsets.fromLTRB(16, 0, 16, 4),
|
||||
@@ -134,12 +116,12 @@ class _ScannerPageState extends State<ScannerPage>
|
||||
style: TextButton.styleFrom(
|
||||
padding: const EdgeInsets.fromLTRB(5, 10, 5, 10),
|
||||
),
|
||||
onPressed: state.isNotEmpty
|
||||
onPressed: state.scans.isNotEmpty
|
||||
? () => Navigator.of(context).push(
|
||||
MaterialPageRoute(
|
||||
builder: (context) => DocumentView(
|
||||
documentBytes: _assembleFileBytes(
|
||||
state,
|
||||
state.scans,
|
||||
forcePdf: true,
|
||||
).then((file) => file.bytes),
|
||||
),
|
||||
@@ -154,19 +136,32 @@ class _ScannerPageState extends State<ScannerPage>
|
||||
style: TextButton.styleFrom(
|
||||
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),
|
||||
),
|
||||
SizedBox(width: 8),
|
||||
TextButton.icon(
|
||||
label: Text(S.of(context)!.upload),
|
||||
style: TextButton.styleFrom(
|
||||
padding: const EdgeInsets.fromLTRB(5, 10, 5, 10),
|
||||
ConnectivityAwareActionWrapper(
|
||||
offlineBuilder: (context, child) {
|
||||
return TextButton.icon(
|
||||
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),
|
||||
TextButton.icon(
|
||||
@@ -174,7 +169,7 @@ class _ScannerPageState extends State<ScannerPage>
|
||||
style: TextButton.styleFrom(
|
||||
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),
|
||||
),
|
||||
SizedBox(width: 12),
|
||||
@@ -196,7 +191,7 @@ class _ScannerPageState extends State<ScannerPage>
|
||||
final cubit = context.read<DocumentScannerCubit>();
|
||||
final file = await _assembleFileBytes(
|
||||
forcePdf: true,
|
||||
context.read<DocumentScannerCubit>().state,
|
||||
context.read<DocumentScannerCubit>().state.scans,
|
||||
);
|
||||
try {
|
||||
final globalSettings =
|
||||
@@ -253,31 +248,27 @@ class _ScannerPageState extends State<ScannerPage>
|
||||
context.read<DocumentScannerCubit>().addScan(file);
|
||||
}
|
||||
|
||||
void _onPrepareDocumentUpload(BuildContext context) async {
|
||||
void _onPrepareDocumentUpload(BuildContext context, List<File> scans) async {
|
||||
final file = await _assembleFileBytes(
|
||||
context.read<DocumentScannerCubit>().state,
|
||||
scans,
|
||||
forcePdf: Hive.box<GlobalSettings>(HiveBoxes.globalSettings)
|
||||
.getValue()!
|
||||
.enforceSinglePagePdfUpload,
|
||||
);
|
||||
final uploadResult = await pushDocumentUploadPreparationPage(
|
||||
context,
|
||||
bytes: file.bytes,
|
||||
final uploadResult = await DocumentUploadRoute(
|
||||
$extra: file.bytes,
|
||||
fileExtension: file.extension,
|
||||
);
|
||||
if ((uploadResult?.success ?? false) && uploadResult?.taskId != null) {
|
||||
).push<DocumentUploadResult>(context);
|
||||
if (uploadResult?.success ?? false) {
|
||||
// For paperless version older than 1.11.3, task id will always be null!
|
||||
context.read<DocumentScannerCubit>().reset();
|
||||
context
|
||||
.read<TaskStatusCubit>()
|
||||
.listenToTaskChanges(uploadResult!.taskId!);
|
||||
// context
|
||||
// .read<PendingTasksNotifier>()
|
||||
// .listenToTaskChanges(uploadResult!.taskId!);
|
||||
}
|
||||
}
|
||||
|
||||
Widget _buildEmptyState(bool isConnected, List<File> scans) {
|
||||
if (scans.isNotEmpty) {
|
||||
return _buildImageGrid(scans);
|
||||
}
|
||||
Widget _buildEmptyState() {
|
||||
return Center(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(8.0),
|
||||
@@ -293,9 +284,15 @@ class _ScannerPageState extends State<ScannerPage>
|
||||
onPressed: () => _openDocumentScanner(context),
|
||||
),
|
||||
Text(S.of(context)!.or),
|
||||
TextButton(
|
||||
child: Text(S.of(context)!.uploadADocumentFromThisDevice),
|
||||
onPressed: isConnected ? _onUploadFromFilesystem : null,
|
||||
ConnectivityAwareActionWrapper(
|
||||
offlineBuilder: (context, child) => TextButton(
|
||||
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],
|
||||
onDelete: () async {
|
||||
try {
|
||||
context.read<DocumentScannerCubit>().removeScan(index);
|
||||
context
|
||||
.read<DocumentScannerCubit>()
|
||||
.removeScan(scans[index]);
|
||||
} on PaperlessApiException catch (error, stackTrace) {
|
||||
showErrorMessage(context, error, stackTrace);
|
||||
}
|
||||
@@ -349,30 +348,34 @@ class _ScannerPageState extends State<ScannerPage>
|
||||
void _onUploadFromFilesystem() async {
|
||||
FilePickerResult? result = await FilePicker.platform.pickFiles(
|
||||
type: FileType.custom,
|
||||
allowedExtensions: supportedFileExtensions,
|
||||
allowedExtensions:
|
||||
supportedFileExtensions.map((e) => e.replaceAll(".", "")).toList(),
|
||||
withData: true,
|
||||
allowMultiple: false,
|
||||
);
|
||||
if (result?.files.single.path != null) {
|
||||
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);
|
||||
if (!supportedFileExtensions.contains(
|
||||
fileDescription.extension.toLowerCase(),
|
||||
)) {
|
||||
if (!supportedFileExtensions.contains(extension.toLowerCase())) {
|
||||
showErrorMessage(
|
||||
context,
|
||||
const PaperlessApiException(ErrorCode.unsupportedFileFormat),
|
||||
);
|
||||
return;
|
||||
}
|
||||
pushDocumentUploadPreparationPage(
|
||||
context,
|
||||
bytes: file.readAsBytesSync(),
|
||||
filename: fileDescription.filename,
|
||||
title: fileDescription.filename,
|
||||
fileExtension: fileDescription.extension,
|
||||
);
|
||||
DocumentUploadRoute(
|
||||
$extra: file.readAsBytesSync(),
|
||||
filename: filename,
|
||||
title: filename,
|
||||
fileExtension: 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_mobile/core/database/tables/local_user_app_state.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/paged_documents_state.dart';
|
||||
import 'package:paperless_mobile/features/settings/model/view_type.dart';
|
||||
@@ -15,6 +16,8 @@ class DocumentSearchCubit extends Cubit<DocumentSearchState>
|
||||
with DocumentPagingBlocMixin {
|
||||
@override
|
||||
final PaperlessDocumentsApi api;
|
||||
@override
|
||||
final ConnectivityStatusService connectivityStatusService;
|
||||
|
||||
@override
|
||||
final DocumentChangedNotifier notifier;
|
||||
@@ -24,8 +27,11 @@ class DocumentSearchCubit extends Cubit<DocumentSearchState>
|
||||
this.api,
|
||||
this.notifier,
|
||||
this._userAppState,
|
||||
) : super(DocumentSearchState(
|
||||
searchHistory: _userAppState.documentSearchHistory)) {
|
||||
this.connectivityStatusService,
|
||||
) : super(
|
||||
DocumentSearchState(
|
||||
searchHistory: _userAppState.documentSearchHistory),
|
||||
) {
|
||||
notifier.addListener(
|
||||
this,
|
||||
onDeleted: remove,
|
||||
@@ -34,22 +40,25 @@ class DocumentSearchCubit extends Cubit<DocumentSearchState>
|
||||
}
|
||||
|
||||
Future<void> search(String query) async {
|
||||
emit(state.copyWith(
|
||||
isLoading: true,
|
||||
suggestions: [],
|
||||
view: SearchView.results,
|
||||
));
|
||||
final normalizedQuery = query.trim();
|
||||
emit(
|
||||
state.copyWith(
|
||||
isLoading: true,
|
||||
suggestions: [],
|
||||
view: SearchView.results,
|
||||
),
|
||||
);
|
||||
final searchFilter = DocumentFilter(
|
||||
query: TextQuery.extended(query),
|
||||
query: TextQuery.extended(normalizedQuery),
|
||||
);
|
||||
|
||||
await updateFilter(filter: searchFilter);
|
||||
emit(
|
||||
state.copyWith(
|
||||
searchHistory: [
|
||||
query,
|
||||
normalizedQuery,
|
||||
...state.searchHistory
|
||||
.whereNot((previousQuery) => previousQuery == query)
|
||||
.whereNot((previousQuery) => previousQuery == normalizedQuery)
|
||||
],
|
||||
),
|
||||
);
|
||||
|
||||
@@ -1,19 +1,15 @@
|
||||
import 'package:animations/animations.dart';
|
||||
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/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/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/widgets/global_settings_builder.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:provider/provider.dart';
|
||||
|
||||
@@ -27,112 +23,96 @@ class DocumentSearchBar extends StatefulWidget {
|
||||
class _DocumentSearchBarState extends State<DocumentSearchBar> {
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Container(
|
||||
margin: EdgeInsets.only(top: 8),
|
||||
child: OpenContainer(
|
||||
transitionDuration: const Duration(milliseconds: 200),
|
||||
transitionType: ContainerTransitionType.fadeThrough,
|
||||
closedElevation: 1,
|
||||
middleColor: Theme.of(context).colorScheme.surfaceVariant,
|
||||
openColor: Theme.of(context).colorScheme.background,
|
||||
closedColor: Theme.of(context).colorScheme.surfaceVariant,
|
||||
closedShape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(56),
|
||||
),
|
||||
closedBuilder: (_, action) {
|
||||
return InkWell(
|
||||
onTap: action,
|
||||
child: ConstrainedBox(
|
||||
constraints: const BoxConstraints(
|
||||
maxWidth: 720,
|
||||
minWidth: 360,
|
||||
maxHeight: 56,
|
||||
minHeight: 48,
|
||||
),
|
||||
child: Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
Flexible(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8),
|
||||
child: Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.center,
|
||||
children: [
|
||||
IconButton(
|
||||
icon: const Icon(Icons.menu),
|
||||
onPressed: Scaffold.of(context).openDrawer,
|
||||
return OpenContainer(
|
||||
transitionDuration: const Duration(milliseconds: 200),
|
||||
transitionType: ContainerTransitionType.fadeThrough,
|
||||
closedElevation: 1,
|
||||
middleColor: Theme.of(context).colorScheme.surfaceVariant,
|
||||
openColor: Theme.of(context).colorScheme.background,
|
||||
closedColor: Theme.of(context).colorScheme.surfaceVariant,
|
||||
closedShape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(56),
|
||||
),
|
||||
closedBuilder: (_, action) {
|
||||
return InkWell(
|
||||
onTap: action,
|
||||
child: ConstrainedBox(
|
||||
constraints: const BoxConstraints(
|
||||
maxWidth: 720,
|
||||
minWidth: 360,
|
||||
maxHeight: 56,
|
||||
minHeight: 48,
|
||||
),
|
||||
child: Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
Flexible(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8),
|
||||
child: Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.center,
|
||||
children: [
|
||||
IconButton(
|
||||
icon: ListenableBuilder(
|
||||
listenable:
|
||||
context.read<ConsumptionChangeNotifier>(),
|
||||
builder: (context, child) {
|
||||
return Badge(
|
||||
isLabelVisible: context
|
||||
.read<ConsumptionChangeNotifier>()
|
||||
.pendingFiles
|
||||
.isNotEmpty,
|
||||
child: const Icon(Icons.menu),
|
||||
backgroundColor: Colors.red,
|
||||
smallSize: 8,
|
||||
);
|
||||
},
|
||||
),
|
||||
Flexible(
|
||||
child: Text(
|
||||
S.of(context)!.searchDocuments,
|
||||
style: Theme.of(context)
|
||||
.textTheme
|
||||
.bodyLarge
|
||||
?.copyWith(
|
||||
fontWeight: FontWeight.w500,
|
||||
color: Theme.of(context).hintColor,
|
||||
),
|
||||
),
|
||||
onPressed: Scaffold.of(context).openDrawer,
|
||||
),
|
||||
Flexible(
|
||||
child: Text(
|
||||
S.of(context)!.searchDocuments,
|
||||
style:
|
||||
Theme.of(context).textTheme.bodyLarge?.copyWith(
|
||||
fontWeight: FontWeight.w500,
|
||||
color: Theme.of(context).hintColor,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
_buildUserAvatar(context),
|
||||
],
|
||||
),
|
||||
),
|
||||
_buildUserAvatar(context),
|
||||
],
|
||||
),
|
||||
);
|
||||
},
|
||||
openBuilder: (_, action) {
|
||||
return MultiProvider(
|
||||
providers: [
|
||||
Provider.value(value: context.read<LabelRepository>()),
|
||||
Provider.value(value: context.read<PaperlessDocumentsApi>()),
|
||||
Provider.value(value: context.read<CacheManager>()),
|
||||
Provider.value(value: context.read<ApiVersion>()),
|
||||
if (context.read<ApiVersion>().hasMultiUserSupport)
|
||||
Provider.value(value: context.read<UserRepository>()),
|
||||
],
|
||||
child: Provider(
|
||||
create: (_) => DocumentSearchCubit(
|
||||
context.read(),
|
||||
context.read(),
|
||||
Hive.box<LocalUserAppState>(HiveBoxes.localUserAppState)
|
||||
.get(LocalUserAccount.current.id)!,
|
||||
),
|
||||
builder: (_, __) => const DocumentSearchPage(),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
openBuilder: (_, action) {
|
||||
return Provider(
|
||||
create: (_) => DocumentSearchCubit(
|
||||
context.read(),
|
||||
context.read(),
|
||||
Hive.box<LocalUserAppState>(HiveBoxes.localUserAppState)
|
||||
.get(context.read<LocalUserAccount>().id)!,
|
||||
context.read(),
|
||||
),
|
||||
child: const DocumentSearchPage(),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
IconButton _buildUserAvatar(BuildContext context) {
|
||||
return IconButton(
|
||||
padding: const EdgeInsets.all(6),
|
||||
icon: GlobalSettingsBuilder(
|
||||
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);
|
||||
},
|
||||
);
|
||||
},
|
||||
),
|
||||
icon: UserAvatar(account: context.watch<LocalUserAccount>()),
|
||||
onPressed: () {
|
||||
final apiVersion = context.read<ApiVersion>();
|
||||
showDialog(
|
||||
context: context,
|
||||
builder: (context) => Provider.value(
|
||||
value: apiVersion,
|
||||
child: const ManageAccountsPage(),
|
||||
),
|
||||
builder: (_) => const ManageAccountsPage(),
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
@@ -4,13 +4,13 @@ import 'dart:math' as math;
|
||||
import 'package:collection/collection.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
import 'package:paperless_mobile/core/navigation/push_routes.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/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/selection/view_type_selection_widget.dart';
|
||||
import 'package:paperless_mobile/generated/l10n/app_localizations.dart';
|
||||
import 'package:paperless_mobile/routes/typed/branches/documents_route.dart';
|
||||
|
||||
class DocumentSearchPage extends StatefulWidget {
|
||||
const DocumentSearchPage({super.key});
|
||||
@@ -186,7 +186,7 @@ class _DocumentSearchPageState extends State<DocumentSearchPage> {
|
||||
children: [
|
||||
Text(
|
||||
S.of(context)!.results,
|
||||
style: Theme.of(context).textTheme.bodySmall,
|
||||
style: Theme.of(context).textTheme.labelMedium,
|
||||
),
|
||||
BlocBuilder<DocumentSearchCubit, DocumentSearchState>(
|
||||
builder: (context, state) {
|
||||
@@ -198,15 +198,15 @@ class _DocumentSearchPageState extends State<DocumentSearchPage> {
|
||||
},
|
||||
)
|
||||
],
|
||||
).padded();
|
||||
).paddedLTRB(16, 8, 8, 8);
|
||||
return CustomScrollView(
|
||||
slivers: [
|
||||
SliverToBoxAdapter(child: header),
|
||||
if (state.hasLoaded && !state.isLoading && state.documents.isEmpty)
|
||||
SliverToBoxAdapter(
|
||||
child: Center(
|
||||
child: Text(S.of(context)!.noMatchesFound),
|
||||
),
|
||||
child: Text(S.of(context)!.noDocumentsFound),
|
||||
).paddedOnly(top: 8),
|
||||
)
|
||||
else
|
||||
SliverAdaptiveDocumentsView(
|
||||
@@ -218,11 +218,8 @@ class _DocumentSearchPageState extends State<DocumentSearchPage> {
|
||||
hasLoaded: state.hasLoaded,
|
||||
enableHeroAnimation: false,
|
||||
onTap: (document) {
|
||||
pushDocumentDetailsRoute(
|
||||
context,
|
||||
document: document,
|
||||
isLabelClickable: false,
|
||||
);
|
||||
DocumentDetailsRoute($extra: document, isLabelClickable: false)
|
||||
.push(context);
|
||||
},
|
||||
)
|
||||
],
|
||||
|
||||
@@ -1,12 +1,9 @@
|
||||
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: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/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/widgets/global_settings_builder.dart';
|
||||
import 'package:paperless_mobile/features/settings/view/widgets/user_avatar.dart';
|
||||
@@ -25,14 +22,11 @@ class SliverSearchBar extends StatelessWidget {
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
if (LocalUserAccount.current.paperlessUser.canViewDocuments) {
|
||||
return SliverAppBar(
|
||||
toolbarHeight: kToolbarHeight,
|
||||
flexibleSpace: Container(
|
||||
margin: const EdgeInsets.symmetric(horizontal: 16.0),
|
||||
child: const DocumentSearchBar(),
|
||||
),
|
||||
if (context.watch<LocalUserAccount>().paperlessUser.canViewDocuments) {
|
||||
return const SliverAppBar(
|
||||
titleSpacing: 8,
|
||||
automaticallyImplyLeading: false,
|
||||
title: DocumentSearchBar(),
|
||||
);
|
||||
} else {
|
||||
return SliverAppBar(
|
||||
@@ -49,18 +43,17 @@ class SliverSearchBar extends StatelessWidget {
|
||||
Hive.box<LocalUserAccount>(HiveBoxes.localUserAccount)
|
||||
.listenable(),
|
||||
builder: (context, box, _) {
|
||||
final account = box.get(settings.currentLoggedInUser!)!;
|
||||
final account = box.get(settings.loggedInUserId!)!;
|
||||
return UserAvatar(account: account);
|
||||
},
|
||||
);
|
||||
},
|
||||
),
|
||||
onPressed: () {
|
||||
final apiVersion = context.read<ApiVersion>();
|
||||
showDialog(
|
||||
context: context,
|
||||
builder: (context) => Provider.value(
|
||||
value: apiVersion,
|
||||
builder: (_) => Provider.value(
|
||||
value: context.read<LocalUserAccount>(),
|
||||
child: const ManageAccountsPage(),
|
||||
),
|
||||
);
|
||||
|
||||
@@ -1,24 +1,26 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:bloc/bloc.dart';
|
||||
import 'package:connectivity_plus/connectivity_plus.dart';
|
||||
import 'package:equatable/equatable.dart';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:paperless_api/paperless_api.dart';
|
||||
import 'package:paperless_mobile/core/repository/label_repository.dart';
|
||||
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';
|
||||
|
||||
class DocumentUploadCubit extends Cubit<DocumentUploadState> {
|
||||
final PaperlessDocumentsApi _documentApi;
|
||||
|
||||
final PendingTasksNotifier _tasksNotifier;
|
||||
final LabelRepository _labelRepository;
|
||||
final Connectivity _connectivity;
|
||||
final ConnectivityStatusService _connectivityStatusService;
|
||||
|
||||
DocumentUploadCubit(
|
||||
this._labelRepository,
|
||||
this._documentApi,
|
||||
this._connectivity,
|
||||
this._connectivityStatusService,
|
||||
this._tasksNotifier,
|
||||
) : super(const DocumentUploadState()) {
|
||||
_labelRepository.addListener(
|
||||
this,
|
||||
@@ -43,7 +45,7 @@ class DocumentUploadCubit extends Cubit<DocumentUploadState> {
|
||||
DateTime? createdAt,
|
||||
int? asn,
|
||||
}) async {
|
||||
return await _documentApi.create(
|
||||
final taskId = await _documentApi.create(
|
||||
bytes,
|
||||
filename: filename,
|
||||
title: title,
|
||||
@@ -53,6 +55,10 @@ class DocumentUploadCubit extends Cubit<DocumentUploadState> {
|
||||
createdAt: createdAt,
|
||||
asn: asn,
|
||||
);
|
||||
if (taskId != null) {
|
||||
_tasksNotifier.listenToTaskChanges(taskId);
|
||||
}
|
||||
return taskId;
|
||||
}
|
||||
|
||||
@override
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
import 'dart:async';
|
||||
import 'dart:typed_data';
|
||||
|
||||
import 'package:flutter/material.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:hive/hive.dart';
|
||||
|
||||
import 'package:intl/date_symbol_data_local.dart';
|
||||
import 'package:intl/intl.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/local_user_account.dart';
|
||||
import 'package:paperless_mobile/core/repository/label_repository.dart';
|
||||
import 'package:paperless_mobile/core/widgets/future_or_builder.dart';
|
||||
import 'package:paperless_mobile/extensions/flutter_extensions.dart';
|
||||
import 'package:paperless_mobile/features/document_upload/cubit/document_upload_cubit.dart';
|
||||
import 'package:paperless_mobile/features/edit_label/view/impl/add_correspondent_page.dart';
|
||||
@@ -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/labels/tags/view/widgets/tags_form_field.dart';
|
||||
import 'package:paperless_mobile/features/labels/view/widgets/label_form_field.dart';
|
||||
import 'package:paperless_mobile/features/sharing/view/widgets/file_thumbnail.dart';
|
||||
import 'package:paperless_mobile/generated/l10n/app_localizations.dart';
|
||||
|
||||
import 'package:paperless_mobile/helpers/message_helpers.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
|
||||
@@ -32,7 +34,7 @@ class DocumentUploadResult {
|
||||
}
|
||||
|
||||
class DocumentUploadPreparationPage extends StatefulWidget {
|
||||
final Uint8List fileBytes;
|
||||
final FutureOr<Uint8List> fileBytes;
|
||||
final String? title;
|
||||
final String? filename;
|
||||
final String? fileExtension;
|
||||
@@ -56,7 +58,6 @@ class _DocumentUploadPreparationPageState
|
||||
static final fileNameDateFormat = DateFormat("yyyy_MM_ddTHH_mm_ss");
|
||||
|
||||
final GlobalKey<FormBuilderState> _formKey = GlobalKey();
|
||||
|
||||
Map<String, String> _errors = {};
|
||||
bool _isUploadLoading = false;
|
||||
late bool _syncTitleAndFilename;
|
||||
@@ -73,18 +74,12 @@ class _DocumentUploadPreparationPageState
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Scaffold(
|
||||
extendBodyBehindAppBar: false,
|
||||
resizeToAvoidBottomInset: true,
|
||||
appBar: AppBar(
|
||||
title: Text(S.of(context)!.prepareDocument),
|
||||
bottom: _isUploadLoading
|
||||
? const PreferredSize(
|
||||
child: LinearProgressIndicator(),
|
||||
preferredSize: Size.fromHeight(4.0))
|
||||
: null,
|
||||
),
|
||||
floatingActionButton: Visibility(
|
||||
visible: MediaQuery.of(context).viewInsets.bottom == 0,
|
||||
child: FloatingActionButton.extended(
|
||||
heroTag: "fab_document_upload",
|
||||
onPressed: _onSubmit,
|
||||
label: Text(S.of(context)!.upload),
|
||||
icon: const Icon(Icons.upload),
|
||||
@@ -94,174 +89,249 @@ class _DocumentUploadPreparationPageState
|
||||
builder: (context, state) {
|
||||
return FormBuilder(
|
||||
key: _formKey,
|
||||
child: ListView(
|
||||
children: [
|
||||
// Title
|
||||
FormBuilderTextField(
|
||||
autovalidateMode: AutovalidateMode.always,
|
||||
name: DocumentModel.titleKey,
|
||||
initialValue:
|
||||
widget.title ?? "scan_${fileNameDateFormat.format(_now)}",
|
||||
validator: (value) {
|
||||
if (value?.trim().isEmpty ?? true) {
|
||||
return S.of(context)!.thisFieldIsRequired;
|
||||
}
|
||||
return null;
|
||||
},
|
||||
decoration: InputDecoration(
|
||||
labelText: S.of(context)!.title,
|
||||
suffixIcon: IconButton(
|
||||
icon: const Icon(Icons.close),
|
||||
onPressed: () {
|
||||
_formKey.currentState?.fields[DocumentModel.titleKey]
|
||||
?.didChange("");
|
||||
if (_syncTitleAndFilename) {
|
||||
_formKey.currentState?.fields[fkFileName]
|
||||
?.didChange("");
|
||||
}
|
||||
},
|
||||
child: NestedScrollView(
|
||||
headerSliverBuilder: (context, innerBoxIsScrolled) => [
|
||||
SliverOverlapAbsorber(
|
||||
handle:
|
||||
NestedScrollView.sliverOverlapAbsorberHandleFor(context),
|
||||
sliver: SliverAppBar(
|
||||
leading: BackButton(),
|
||||
pinned: true,
|
||||
expandedHeight: 150,
|
||||
flexibleSpace: FlexibleSpaceBar(
|
||||
background: FutureOrBuilder<Uint8List>(
|
||||
future: widget.fileBytes,
|
||||
builder: (context, snapshot) {
|
||||
if (!snapshot.hasData) {
|
||||
return SizedBox.shrink();
|
||||
}
|
||||
return FileThumbnail(
|
||||
bytes: snapshot.data!,
|
||||
fit: BoxFit.fitWidth,
|
||||
width: MediaQuery.sizeOf(context).width,
|
||||
);
|
||||
},
|
||||
),
|
||||
title: Text(S.of(context)!.prepareDocument),
|
||||
collapseMode: CollapseMode.pin,
|
||||
),
|
||||
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);
|
||||
},
|
||||
bottom: _isUploadLoading
|
||||
? PreferredSize(
|
||||
child: LinearProgressIndicator(),
|
||||
preferredSize: Size.fromHeight(4.0),
|
||||
)
|
||||
: null,
|
||||
),
|
||||
),
|
||||
// Correspondent
|
||||
if (LocalUserAccount
|
||||
.current.paperlessUser.canViewCorrespondents)
|
||||
LabelFormField<Correspondent>(
|
||||
showAnyAssignedOption: false,
|
||||
showNotAssignedOption: false,
|
||||
addLabelPageBuilder: (initialName) => MultiProvider(
|
||||
providers: [
|
||||
Provider.value(
|
||||
value: context.read<LabelRepository>(),
|
||||
],
|
||||
body: Padding(
|
||||
padding: const EdgeInsets.only(top: 16.0),
|
||||
child: Builder(
|
||||
builder: (context) {
|
||||
return CustomScrollView(
|
||||
slivers: [
|
||||
SliverOverlapInjector(
|
||||
handle:
|
||||
NestedScrollView.sliverOverlapAbsorberHandleFor(
|
||||
context),
|
||||
),
|
||||
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: LocalUserAccount
|
||||
.current.paperlessUser.canCreateCorrespondents,
|
||||
),
|
||||
// Document type
|
||||
if (LocalUserAccount.current.paperlessUser.canViewDocumentTypes)
|
||||
LabelFormField<DocumentType>(
|
||||
showAnyAssignedOption: false,
|
||||
showNotAssignedOption: false,
|
||||
addLabelPageBuilder: (initialName) => MultiProvider(
|
||||
providers: [
|
||||
Provider.value(
|
||||
value: context.read<LabelRepository>(),
|
||||
SliverList.list(
|
||||
children: [
|
||||
// Title
|
||||
FormBuilderTextField(
|
||||
autovalidateMode: AutovalidateMode.always,
|
||||
name: DocumentModel.titleKey,
|
||||
initialValue: widget.title ??
|
||||
"scan_${fileNameDateFormat.format(_now)}",
|
||||
validator: (value) {
|
||||
if (value?.trim().isEmpty ?? true) {
|
||||
return S.of(context)!.thisFieldIsRequired;
|
||||
}
|
||||
return null;
|
||||
},
|
||||
decoration: InputDecoration(
|
||||
labelText: S.of(context)!.title,
|
||||
suffixIcon: IconButton(
|
||||
icon: const Icon(Icons.close),
|
||||
onPressed: () {
|
||||
_formKey.currentState
|
||||
?.fields[DocumentModel.titleKey]
|
||||
?.didChange("");
|
||||
if (_syncTitleAndFilename) {
|
||||
_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);
|
||||
final asn = fv[DocumentModel.asnKey] as int?;
|
||||
final taskId = await cubit.upload(
|
||||
widget.fileBytes,
|
||||
await widget.fileBytes,
|
||||
filename: _padWithExtension(
|
||||
_formKey.currentState?.value[fkFileName],
|
||||
widget.fileExtension,
|
||||
),
|
||||
userId: Hive.box<GlobalSettings>(HiveBoxes.globalSettings)
|
||||
.getValue()!
|
||||
.currentLoggedInUser!,
|
||||
.loggedInUserId!,
|
||||
title: title,
|
||||
documentType: docType,
|
||||
correspondent: correspondent,
|
||||
@@ -308,10 +378,7 @@ class _DocumentUploadPreparationPageState
|
||||
context,
|
||||
S.of(context)!.documentSuccessfullyUploadedProcessing,
|
||||
);
|
||||
Navigator.pop(
|
||||
context,
|
||||
DocumentUploadResult(true, taskId),
|
||||
);
|
||||
context.pop(DocumentUploadResult(true, taskId));
|
||||
} on PaperlessApiException catch (error, stackTrace) {
|
||||
showErrorMessage(context, error, stackTrace);
|
||||
} on PaperlessFormValidationException catch (exception) {
|
||||
@@ -336,4 +403,33 @@ class _DocumentUploadPreparationPageState
|
||||
String _formatFilename(String source) {
|
||||
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/notifier/document_changed_notifier.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/paged_documents_state.dart';
|
||||
import 'package:paperless_mobile/features/settings/model/view_type.dart';
|
||||
@@ -20,6 +21,8 @@ class DocumentsCubit extends HydratedCubit<DocumentsState>
|
||||
final PaperlessDocumentsApi api;
|
||||
|
||||
final LabelRepository _labelRepository;
|
||||
@override
|
||||
final ConnectivityStatusService connectivityStatusService;
|
||||
|
||||
@override
|
||||
final DocumentChangedNotifier notifier;
|
||||
@@ -31,6 +34,7 @@ class DocumentsCubit extends HydratedCubit<DocumentsState>
|
||||
this.notifier,
|
||||
this._labelRepository,
|
||||
this._userState,
|
||||
this.connectivityStatusService,
|
||||
) : super(DocumentsState(
|
||||
filter: _userState.currentDocumentFilter,
|
||||
viewType: _userState.documentsPageViewType,
|
||||
|
||||
@@ -1,28 +1,31 @@
|
||||
import 'package:badges/badges.dart' as b;
|
||||
import 'package:collection/collection.dart';
|
||||
import 'package:defer_pointer/defer_pointer.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
import 'package:paperless_api/paperless_api.dart';
|
||||
import 'package:paperless_mobile/core/bloc/connectivity_cubit.dart';
|
||||
import 'package:paperless_mobile/core/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/features/app_drawer/view/app_drawer.dart';
|
||||
import 'package:paperless_mobile/features/document_search/view/sliver_search_bar.dart';
|
||||
import 'package:paperless_mobile/features/documents/cubit/documents_cubit.dart';
|
||||
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/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/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/view_type_selection_widget.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/view/saved_view_list.dart';
|
||||
import 'package:paperless_mobile/features/tasks/cubit/task_status_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/helpers/message_helpers.dart';
|
||||
import 'package:paperless_mobile/routes/typed/branches/documents_route.dart';
|
||||
import 'package:sliver_tools/sliver_tools.dart';
|
||||
|
||||
class DocumentFilterIntent {
|
||||
final DocumentFilter? filter;
|
||||
@@ -41,283 +44,260 @@ class DocumentsPage extends StatefulWidget {
|
||||
State<DocumentsPage> createState() => _DocumentsPageState();
|
||||
}
|
||||
|
||||
class _DocumentsPageState extends State<DocumentsPage>
|
||||
with SingleTickerProviderStateMixin {
|
||||
class _DocumentsPageState extends State<DocumentsPage> {
|
||||
final SliverOverlapAbsorberHandle searchBarHandle =
|
||||
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
|
||||
void initState() {
|
||||
super.initState();
|
||||
final showSavedViews =
|
||||
LocalUserAccount.current.paperlessUser.canViewSavedViews;
|
||||
_tabController = TabController(
|
||||
length: showSavedViews ? 2 : 1,
|
||||
vsync: this,
|
||||
);
|
||||
Future.wait([
|
||||
context.read<DocumentsCubit>().reload(),
|
||||
context.read<SavedViewCubit>().reload(),
|
||||
]).onError<PaperlessApiException>(
|
||||
(error, stackTrace) {
|
||||
showErrorMessage(context, error, stackTrace);
|
||||
return [];
|
||||
},
|
||||
);
|
||||
_tabController.addListener(_tabChangesListener);
|
||||
// context.read<PendingTasksNotifier>().addListener(_onTasksChanged);
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
_nestedScrollViewKey.currentState!.innerController
|
||||
.addListener(_scrollExtentChangedListener);
|
||||
});
|
||||
}
|
||||
|
||||
void _tabChangesListener() {
|
||||
setState(() => _currentTab = _tabController.index);
|
||||
void _onTasksChanged() {
|
||||
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
|
||||
void dispose() {
|
||||
_tabController.dispose();
|
||||
_nestedScrollViewKey.currentState?.innerController
|
||||
.removeListener(_scrollExtentChangedListener);
|
||||
// context.read<PendingTasksNotifier>().removeListener(_onTasksChanged);
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return BlocListener<TaskStatusCubit, TaskStatusState>(
|
||||
return BlocConsumer<ConnectivityCubit, ConnectivityState>(
|
||||
listenWhen: (previous, current) =>
|
||||
!previous.isSuccess && current.isSuccess,
|
||||
previous != ConnectivityState.connected &&
|
||||
current == ConnectivityState.connected,
|
||||
listener: (context, state) {
|
||||
showSnackBar(
|
||||
context,
|
||||
S.of(context)!.newDocumentAvailable,
|
||||
action: SnackBarActionConfig(
|
||||
label: S.of(context)!.reload,
|
||||
onPressed: () {
|
||||
context.read<TaskStatusCubit>().acknowledgeCurrentTask();
|
||||
context.read<DocumentsCubit>().reload();
|
||||
},
|
||||
),
|
||||
duration: const Duration(seconds: 10),
|
||||
);
|
||||
_reloadData();
|
||||
},
|
||||
child: BlocConsumer<ConnectivityCubit, ConnectivityState>(
|
||||
listenWhen: (previous, current) =>
|
||||
previous != ConnectivityState.connected &&
|
||||
current == ConnectivityState.connected,
|
||||
listener: (context, state) {
|
||||
try {
|
||||
context.read<DocumentsCubit>().reload();
|
||||
} on PaperlessApiException catch (error, stackTrace) {
|
||||
showErrorMessage(context, error, stackTrace);
|
||||
}
|
||||
},
|
||||
builder: (context, connectivityState) {
|
||||
return SafeArea(
|
||||
top: true,
|
||||
child: Scaffold(
|
||||
drawer: const AppDrawer(),
|
||||
floatingActionButton: BlocBuilder<DocumentsCubit, DocumentsState>(
|
||||
builder: (context, state) {
|
||||
final appliedFiltersCount = state.filter.appliedFiltersCount;
|
||||
final show = state.selection.isEmpty;
|
||||
final canReset = state.filter.appliedFiltersCount > 0;
|
||||
return AnimatedScale(
|
||||
scale: show ? 1 : 0,
|
||||
duration: const Duration(milliseconds: 200),
|
||||
curve: Curves.easeIn,
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.end,
|
||||
children: [
|
||||
if (canReset)
|
||||
Padding(
|
||||
padding: const EdgeInsets.all(8.0),
|
||||
child: FloatingActionButton.small(
|
||||
key: UniqueKey(),
|
||||
backgroundColor: Theme.of(context)
|
||||
.colorScheme
|
||||
.onPrimaryContainer,
|
||||
onPressed: () {
|
||||
context.read<DocumentsCubit>().updateFilter();
|
||||
},
|
||||
child: Icon(
|
||||
Icons.refresh,
|
||||
color: Theme.of(context)
|
||||
.colorScheme
|
||||
.primaryContainer,
|
||||
builder: (context, connectivityState) {
|
||||
return SafeArea(
|
||||
top: true,
|
||||
child: Scaffold(
|
||||
drawer: const AppDrawer(),
|
||||
floatingActionButton: BlocBuilder<DocumentsCubit, DocumentsState>(
|
||||
builder: (context, state) {
|
||||
final show = state.selection.isEmpty;
|
||||
final canReset = state.filter.appliedFiltersCount > 0;
|
||||
if (show) {
|
||||
return Column(
|
||||
mainAxisAlignment: MainAxisAlignment.end,
|
||||
children: [
|
||||
DeferredPointerHandler(
|
||||
child: Stack(
|
||||
clipBehavior: Clip.none,
|
||||
children: [
|
||||
FloatingActionButton.extended(
|
||||
extendedPadding: _showExtendedFab
|
||||
? null
|
||||
: const EdgeInsets.symmetric(horizontal: 16),
|
||||
heroTag: "fab_documents_page_filter",
|
||||
label: AnimatedSwitcher(
|
||||
duration: const Duration(milliseconds: 150),
|
||||
transitionBuilder: (child, animation) {
|
||||
return FadeTransition(
|
||||
opacity: animation,
|
||||
child: SizeTransition(
|
||||
sizeFactor: animation,
|
||||
axis: Axis.horizontal,
|
||||
child: child,
|
||||
),
|
||||
);
|
||||
},
|
||||
child: _showExtendedFab
|
||||
? Row(
|
||||
children: [
|
||||
const Icon(
|
||||
Icons.filter_alt_outlined,
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Text(
|
||||
S.of(context)!.filterDocuments,
|
||||
),
|
||||
],
|
||||
)
|
||||
: const Icon(Icons.filter_alt_outlined),
|
||||
),
|
||||
onPressed: _openDocumentFilter,
|
||||
),
|
||||
),
|
||||
b.Badge(
|
||||
position: b.BadgePosition.topEnd(top: -12, end: -6),
|
||||
showBadge: appliedFiltersCount > 0,
|
||||
badgeContent: Text(
|
||||
'$appliedFiltersCount',
|
||||
style: const TextStyle(
|
||||
color: Colors.white,
|
||||
),
|
||||
),
|
||||
animationType: b.BadgeAnimationType.fade,
|
||||
badgeColor: Colors.red,
|
||||
child: AnimatedSwitcher(
|
||||
duration: const Duration(milliseconds: 250),
|
||||
child: (_currentTab == 0)
|
||||
? FloatingActionButton(
|
||||
child:
|
||||
const Icon(Icons.filter_alt_outlined),
|
||||
onPressed: _openDocumentFilter,
|
||||
)
|
||||
: FloatingActionButton(
|
||||
child: const Icon(Icons.add),
|
||||
onPressed: () =>
|
||||
_onCreateSavedView(state.filter),
|
||||
if (canReset)
|
||||
Positioned(
|
||||
top: -20,
|
||||
right: -8,
|
||||
child: DeferPointer(
|
||||
paintOnTop: true,
|
||||
child: Material(
|
||||
color: Theme.of(context).colorScheme.error,
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
child: InkWell(
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
onTap: () {
|
||||
HapticFeedback.mediumImpact();
|
||||
_onResetFilter();
|
||||
},
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
mainAxisAlignment:
|
||||
MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
if (_showExtendedFab)
|
||||
Text(
|
||||
"Reset (${state.filter.appliedFiltersCount})",
|
||||
style: Theme.of(context)
|
||||
.textTheme
|
||||
.labelLarge
|
||||
?.copyWith(
|
||||
color: Theme.of(context)
|
||||
.colorScheme
|
||||
.onError,
|
||||
),
|
||||
).padded()
|
||||
else
|
||||
Icon(
|
||||
Icons.replay,
|
||||
color: Theme.of(context)
|
||||
.colorScheme
|
||||
.onError,
|
||||
).padded(4),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
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) {
|
||||
return _buildSavedViewsTab(
|
||||
connectivityState,
|
||||
context,
|
||||
);
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
);
|
||||
} else {
|
||||
return const SizedBox.shrink();
|
||||
}
|
||||
},
|
||||
),
|
||||
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) {
|
||||
// Listen for scroll notifications to load new data.
|
||||
// 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 currentState = context.read<DocumentsCubit>().state;
|
||||
if (max == 0 ||
|
||||
_currentTab != 0 ||
|
||||
currState.isLoading ||
|
||||
currState.isLastPageLoaded) {
|
||||
currentState.isLoading ||
|
||||
currentState.isLastPageLoaded) {
|
||||
return false;
|
||||
}
|
||||
|
||||
final offset = notification.metrics.pixels;
|
||||
if (offset >= max * 0.7) {
|
||||
context
|
||||
.read<DocumentsCubit>()
|
||||
@@ -356,29 +338,77 @@ class _DocumentsPageState extends State<DocumentsPage>
|
||||
return false;
|
||||
},
|
||||
child: RefreshIndicator(
|
||||
edgeOffset: kTextTabBarHeight,
|
||||
onRefresh: _onReloadDocuments,
|
||||
edgeOffset: kTextTabBarHeight + 2,
|
||||
onRefresh: _reloadData,
|
||||
notificationPredicate: (_) => connectivityState.isConnected,
|
||||
child: CustomScrollView(
|
||||
key: const PageStorageKey<String>("documents"),
|
||||
slivers: <Widget>[
|
||||
SliverOverlapInjector(handle: searchBarHandle),
|
||||
SliverOverlapInjector(handle: tabBarHandle),
|
||||
_buildViewActions(),
|
||||
SliverOverlapInjector(handle: savedViewsHandle),
|
||||
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>(
|
||||
builder: (context, state) {
|
||||
if (state.hasLoaded && state.documents.isEmpty) {
|
||||
return SliverToBoxAdapter(
|
||||
child: DocumentsEmptyState(
|
||||
state: state,
|
||||
onReset: context.read<DocumentsCubit>().resetFilter,
|
||||
onReset: _onResetFilter,
|
||||
),
|
||||
);
|
||||
}
|
||||
final allowToggleFilter = state.selection.isEmpty;
|
||||
return SliverAdaptiveDocumentsView(
|
||||
viewType: state.viewType,
|
||||
onTap: _openDetails,
|
||||
onTap: (document) {
|
||||
DocumentDetailsRoute($extra: document).push(context);
|
||||
},
|
||||
onSelected:
|
||||
context.read<DocumentsCubit>().toggleDocumentSelection,
|
||||
hasInternetConnection: connectivityState.isConnected,
|
||||
@@ -404,10 +434,12 @@ class _DocumentsPageState extends State<DocumentsPage>
|
||||
}
|
||||
|
||||
Widget _buildViewActions() {
|
||||
return SliverToBoxAdapter(
|
||||
child: BlocBuilder<DocumentsCubit, DocumentsState>(
|
||||
builder: (context, state) {
|
||||
return Row(
|
||||
return BlocBuilder<DocumentsCubit, DocumentsState>(
|
||||
builder: (context, state) {
|
||||
return Container(
|
||||
padding: const EdgeInsets.all(4),
|
||||
color: Theme.of(context).colorScheme.background,
|
||||
child: Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
SortDocumentsButton(
|
||||
@@ -418,23 +450,12 @@ class _DocumentsPageState extends State<DocumentsPage>
|
||||
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 {
|
||||
final draggableSheetController = DraggableScrollableController();
|
||||
final filterIntent = await showModalBottomSheet<DocumentFilterIntent>(
|
||||
@@ -476,7 +497,7 @@ class _DocumentsPageState extends State<DocumentsPage>
|
||||
if (filterIntent != null) {
|
||||
try {
|
||||
if (filterIntent.shouldReset) {
|
||||
await context.read<DocumentsCubit>().resetFilter();
|
||||
await _onResetFilter();
|
||||
} else {
|
||||
await context
|
||||
.read<DocumentsCubit>()
|
||||
@@ -488,13 +509,6 @@ class _DocumentsPageState extends State<DocumentsPage>
|
||||
}
|
||||
}
|
||||
|
||||
void _openDetails(DocumentModel document) {
|
||||
pushDocumentDetailsRoute(
|
||||
context,
|
||||
document: document,
|
||||
);
|
||||
}
|
||||
|
||||
void _addTagToFilter(int tagId) {
|
||||
final cubit = context.read<DocumentsCubit>();
|
||||
try {
|
||||
@@ -632,21 +646,46 @@ class _DocumentsPageState extends State<DocumentsPage>
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _onReloadDocuments() async {
|
||||
try {
|
||||
// We do not await here on purpose so we can show a linear progress indicator below the app bar.
|
||||
await context.read<DocumentsCubit>().reload();
|
||||
} on PaperlessApiException catch (error, stackTrace) {
|
||||
showErrorMessage(context, error, stackTrace);
|
||||
}
|
||||
}
|
||||
///
|
||||
/// Resets the current filter and scrolls all the way to the top of the view.
|
||||
/// If a saved view is currently selected and the filter has changed,
|
||||
/// the user will be shown a dialog informing them about the changes.
|
||||
/// The user can then decide whether to abort the reset or to continue and discard the changes.
|
||||
Future<void> _onResetFilter() async {
|
||||
final cubit = context.read<DocumentsCubit>();
|
||||
final savedViewCubit = context.read<SavedViewCubit>();
|
||||
|
||||
Future<void> _onReloadSavedViews() async {
|
||||
try {
|
||||
// We do not await here on purpose so we can show a linear progress indicator below the app bar.
|
||||
await context.read<SavedViewCubit>().reload();
|
||||
} on PaperlessApiException catch (error, stackTrace) {
|
||||
showErrorMessage(context, error, stackTrace);
|
||||
void toTop() async {
|
||||
await _nestedScrollViewKey.currentState?.outerController.animateTo(
|
||||
0,
|
||||
duration: const Duration(milliseconds: 300),
|
||||
curve: Curves.easeOut,
|
||||
);
|
||||
}
|
||||
|
||||
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_cache_manager/flutter_cache_manager.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:shimmer/shimmer.dart';
|
||||
|
||||
@@ -12,6 +14,7 @@ class DocumentPreview extends StatelessWidget {
|
||||
final double borderRadius;
|
||||
final bool enableHero;
|
||||
final double scale;
|
||||
final bool isClickable;
|
||||
|
||||
const DocumentPreview({
|
||||
super.key,
|
||||
@@ -21,15 +24,26 @@ class DocumentPreview extends StatelessWidget {
|
||||
this.borderRadius = 12.0,
|
||||
this.enableHero = true,
|
||||
this.scale = 1.1,
|
||||
this.isClickable = true,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return HeroMode(
|
||||
enabled: enableHero,
|
||||
child: Hero(
|
||||
tag: "thumb_${document.id}",
|
||||
child: _buildPreview(context),
|
||||
return ConnectivityAwareActionWrapper(
|
||||
child: GestureDetector(
|
||||
behavior: HitTestBehavior.translucent,
|
||||
onTap: isClickable
|
||||
? () => 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/services.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/features/paged_document_view/cubit/paged_documents_state.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 {
|
||||
final DocumentPagingState state;
|
||||
final VoidCallback? onReset;
|
||||
|
||||
const DocumentsEmptyState({
|
||||
Key? key,
|
||||
required this.state,
|
||||
@@ -17,18 +18,24 @@ class DocumentsEmptyState extends StatelessWidget {
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Center(
|
||||
child: EmptyState(
|
||||
title: S.of(context)!.oops,
|
||||
subtitle: S.of(context)!.thereSeemsToBeNothingHere,
|
||||
bottomChild: state.filter != DocumentFilter.initial && onReset != null
|
||||
? TextButton(
|
||||
onPressed: onReset,
|
||||
child: Text(
|
||||
S.of(context)!.resetFilter,
|
||||
),
|
||||
).padded()
|
||||
: null,
|
||||
),
|
||||
child: Column(
|
||||
children: [
|
||||
Text(
|
||||
S.of(context)!.noDocumentsFound,
|
||||
style: Theme.of(context).textTheme.titleSmall,
|
||||
),
|
||||
if (state.filter != DocumentFilter.initial && onReset != null)
|
||||
TextButton(
|
||||
onPressed: () {
|
||||
HapticFeedback.mediumImpact();
|
||||
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_html/flutter_html.dart';
|
||||
import 'package:hive_flutter/adapters.dart';
|
||||
import 'package:intl/intl.dart';
|
||||
import 'package:paperless_api/paperless_api.dart';
|
||||
import 'package:paperless_mobile/core/config/hive/hive_config.dart';
|
||||
import 'package:paperless_mobile/core/database/tables/global_settings.dart';
|
||||
import 'package:paperless_mobile/core/database/tables/local_user_account.dart';
|
||||
import 'package:paperless_mobile/core/repository/label_repository.dart';
|
||||
import 'package:paperless_mobile/extensions/flutter_extensions.dart';
|
||||
import 'package:paperless_mobile/features/documents/view/widgets/document_preview.dart';
|
||||
@@ -32,6 +37,12 @@ class DocumentDetailedItem extends DocumentItem {
|
||||
|
||||
@override
|
||||
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 insets = MediaQuery.of(context).viewInsets;
|
||||
final padding = MediaQuery.of(context).viewPadding;
|
||||
@@ -104,48 +115,51 @@ class DocumentDetailedItem extends DocumentItem {
|
||||
maxLines: 2,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
).paddedLTRB(8, 0, 8, 4),
|
||||
Row(
|
||||
children: [
|
||||
const Icon(
|
||||
Icons.person_outline,
|
||||
size: 16,
|
||||
).paddedOnly(right: 4.0),
|
||||
CorrespondentWidget(
|
||||
onSelected: onCorrespondentSelected,
|
||||
textStyle: Theme.of(context).textTheme.titleSmall?.apply(
|
||||
color: Theme.of(context).colorScheme.onSurfaceVariant,
|
||||
),
|
||||
correspondent: context
|
||||
.watch<LabelRepository>()
|
||||
.state
|
||||
.correspondents[document.correspondent],
|
||||
),
|
||||
],
|
||||
).paddedLTRB(8, 0, 8, 4),
|
||||
Row(
|
||||
children: [
|
||||
const Icon(
|
||||
Icons.description_outlined,
|
||||
size: 16,
|
||||
).paddedOnly(right: 4.0),
|
||||
DocumentTypeWidget(
|
||||
onSelected: onDocumentTypeSelected,
|
||||
textStyle: Theme.of(context).textTheme.titleSmall?.apply(
|
||||
color: Theme.of(context).colorScheme.onSurfaceVariant,
|
||||
),
|
||||
documentType: context
|
||||
.watch<LabelRepository>()
|
||||
.state
|
||||
.documentTypes[document.documentType],
|
||||
),
|
||||
],
|
||||
).paddedLTRB(8, 0, 8, 4),
|
||||
TagsWidget(
|
||||
tags: document.tags
|
||||
.map((e) => context.watch<LabelRepository>().state.tags[e]!)
|
||||
.toList(),
|
||||
onTagSelected: onTagSelected,
|
||||
).padded(),
|
||||
if (paperlessUser.canViewCorrespondents)
|
||||
Row(
|
||||
children: [
|
||||
const Icon(
|
||||
Icons.person_outline,
|
||||
size: 16,
|
||||
).paddedOnly(right: 4.0),
|
||||
CorrespondentWidget(
|
||||
onSelected: onCorrespondentSelected,
|
||||
textStyle: Theme.of(context).textTheme.titleSmall?.apply(
|
||||
color: Theme.of(context).colorScheme.onSurfaceVariant,
|
||||
),
|
||||
correspondent: context
|
||||
.watch<LabelRepository>()
|
||||
.state
|
||||
.correspondents[document.correspondent],
|
||||
),
|
||||
],
|
||||
).paddedLTRB(8, 0, 8, 4),
|
||||
if (paperlessUser.canViewDocumentTypes)
|
||||
Row(
|
||||
children: [
|
||||
const Icon(
|
||||
Icons.description_outlined,
|
||||
size: 16,
|
||||
).paddedOnly(right: 4.0),
|
||||
DocumentTypeWidget(
|
||||
onSelected: onDocumentTypeSelected,
|
||||
textStyle: Theme.of(context).textTheme.titleSmall?.apply(
|
||||
color: Theme.of(context).colorScheme.onSurfaceVariant,
|
||||
),
|
||||
documentType: context
|
||||
.watch<LabelRepository>()
|
||||
.state
|
||||
.documentTypes[document.documentType],
|
||||
),
|
||||
],
|
||||
).paddedLTRB(8, 0, 8, 4),
|
||||
if (paperlessUser.canViewTags)
|
||||
TagsWidget(
|
||||
tags: document.tags
|
||||
.map((e) => context.watch<LabelRepository>().state.tags[e]!)
|
||||
.toList(),
|
||||
onTagSelected: onTagSelected,
|
||||
).padded(),
|
||||
if (highlights != null)
|
||||
Html(
|
||||
data: '<p>${highlights!}</p>',
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:intl/intl.dart';
|
||||
import 'package:paperless_api/paperless_api.dart';
|
||||
import 'package:paperless_mobile/core/database/tables/local_user_account.dart';
|
||||
import 'package:paperless_mobile/core/repository/label_repository.dart';
|
||||
import 'package:paperless_mobile/features/documents/view/widgets/document_preview.dart';
|
||||
import 'package:paperless_mobile/features/documents/view/widgets/items/document_item.dart';
|
||||
@@ -26,6 +28,7 @@ class DocumentGridItem extends DocumentItem {
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
var currentUser = context.watch<LocalUserAccount>().paperlessUser;
|
||||
return Padding(
|
||||
padding: const EdgeInsets.all(8.0),
|
||||
child: Card(
|
||||
@@ -64,15 +67,16 @@ class DocumentGridItem extends DocumentItem {
|
||||
const SliverToBoxAdapter(
|
||||
child: SizedBox(width: 8),
|
||||
),
|
||||
TagsWidget.sliver(
|
||||
tags: document.tags
|
||||
.map((e) => context
|
||||
.watch<LabelRepository>()
|
||||
.state
|
||||
.tags[e]!)
|
||||
.toList(),
|
||||
onTagSelected: onTagSelected,
|
||||
),
|
||||
if (currentUser.canViewTags)
|
||||
TagsWidget.sliver(
|
||||
tags: document.tags
|
||||
.map((e) => context
|
||||
.watch<LabelRepository>()
|
||||
.state
|
||||
.tags[e]!)
|
||||
.toList(),
|
||||
onTagSelected: onTagSelected,
|
||||
),
|
||||
const SliverToBoxAdapter(
|
||||
child: SizedBox(width: 8),
|
||||
),
|
||||
@@ -90,20 +94,22 @@ class DocumentGridItem extends DocumentItem {
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
CorrespondentWidget(
|
||||
correspondent: context
|
||||
.watch<LabelRepository>()
|
||||
.state
|
||||
.correspondents[document.correspondent],
|
||||
onSelected: onCorrespondentSelected,
|
||||
),
|
||||
DocumentTypeWidget(
|
||||
documentType: context
|
||||
.watch<LabelRepository>()
|
||||
.state
|
||||
.documentTypes[document.documentType],
|
||||
onSelected: onDocumentTypeSelected,
|
||||
),
|
||||
if (currentUser.canViewCorrespondents)
|
||||
CorrespondentWidget(
|
||||
correspondent: context
|
||||
.watch<LabelRepository>()
|
||||
.state
|
||||
.correspondents[document.correspondent],
|
||||
onSelected: onCorrespondentSelected,
|
||||
),
|
||||
if (currentUser.canViewDocumentTypes)
|
||||
DocumentTypeWidget(
|
||||
documentType: context
|
||||
.watch<LabelRepository>()
|
||||
.state
|
||||
.documentTypes[document.documentType],
|
||||
onSelected: onDocumentTypeSelected,
|
||||
),
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(bottom: 8.0),
|
||||
child: Text(
|
||||
|
||||
@@ -11,8 +11,10 @@ import 'package:provider/provider.dart';
|
||||
class DocumentListItem extends DocumentItem {
|
||||
static const _a4AspectRatio = 1 / 1.4142;
|
||||
|
||||
final Color? backgroundColor;
|
||||
const DocumentListItem({
|
||||
super.key,
|
||||
this.backgroundColor,
|
||||
required super.document,
|
||||
required super.isSelected,
|
||||
required super.isSelectionActive,
|
||||
@@ -29,91 +31,90 @@ class DocumentListItem extends DocumentItem {
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final labels = context.watch<LabelRepository>().state;
|
||||
return Material(
|
||||
child: ListTile(
|
||||
dense: true,
|
||||
selected: isSelected,
|
||||
onTap: () => _onTap(),
|
||||
selectedTileColor: Theme.of(context).colorScheme.inversePrimary,
|
||||
onLongPress: () => onSelected?.call(document),
|
||||
title: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
mainAxisAlignment: MainAxisAlignment.spaceAround,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
AbsorbPointer(
|
||||
absorbing: isSelectionActive,
|
||||
child: CorrespondentWidget(
|
||||
isClickable: isLabelClickable,
|
||||
correspondent: context
|
||||
.watch<LabelRepository>()
|
||||
.state
|
||||
.correspondents[document.correspondent],
|
||||
onSelected: onCorrespondentSelected,
|
||||
),
|
||||
return ListTile(
|
||||
tileColor: backgroundColor,
|
||||
dense: true,
|
||||
selected: isSelected,
|
||||
onTap: () => _onTap(),
|
||||
selectedTileColor: Theme.of(context).colorScheme.inversePrimary,
|
||||
onLongPress: onSelected != null ? () => onSelected!(document) : null,
|
||||
title: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
mainAxisAlignment: MainAxisAlignment.spaceAround,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
AbsorbPointer(
|
||||
absorbing: isSelectionActive,
|
||||
child: CorrespondentWidget(
|
||||
isClickable: isLabelClickable,
|
||||
correspondent: context
|
||||
.watch<LabelRepository>()
|
||||
.state
|
||||
.correspondents[document.correspondent],
|
||||
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),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
subtitle: Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: 4),
|
||||
child: RichText(
|
||||
maxLines: 1,
|
||||
],
|
||||
),
|
||||
Text(
|
||||
document.title,
|
||||
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,
|
||||
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),
|
||||
),
|
||||
),
|
||||
),
|
||||
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_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/database/tables/local_user_account.dart';
|
||||
@@ -18,10 +19,12 @@ class DocumentFilterForm extends StatefulWidget {
|
||||
static const fkAddedAt = DocumentModel.addedKey;
|
||||
|
||||
static DocumentFilter assembleFilter(
|
||||
GlobalKey<FormBuilderState> formKey, DocumentFilter initialFilter) {
|
||||
GlobalKey<FormBuilderState> formKey,
|
||||
DocumentFilter initialFilter,
|
||||
) {
|
||||
formKey.currentState?.save();
|
||||
final v = formKey.currentState!.value;
|
||||
return DocumentFilter(
|
||||
return initialFilter.copyWith(
|
||||
correspondent:
|
||||
v[DocumentFilterForm.fkCorrespondent] as IdQueryParameter? ??
|
||||
DocumentFilter.initial.correspondent,
|
||||
@@ -35,11 +38,7 @@ class DocumentFilterForm extends StatefulWidget {
|
||||
DocumentFilter.initial.query,
|
||||
created: (v[DocumentFilterForm.fkCreatedAt] as DateRangeQuery),
|
||||
added: (v[DocumentFilterForm.fkAddedAt] as DateRangeQuery),
|
||||
asnQuery: initialFilter.asnQuery,
|
||||
page: 1,
|
||||
pageSize: initialFilter.pageSize,
|
||||
sortField: initialFilter.sortField,
|
||||
sortOrder: initialFilter.sortOrder,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -160,8 +159,10 @@ class _DocumentFilterFormState extends State<DocumentFilterForm> {
|
||||
initialValue: widget.initialFilter.documentType,
|
||||
prefixIcon: const Icon(Icons.description_outlined),
|
||||
allowSelectUnassigned: false,
|
||||
canCreateNewLabel:
|
||||
LocalUserAccount.current.paperlessUser.canCreateDocumentTypes,
|
||||
canCreateNewLabel: context
|
||||
.watch<LocalUserAccount>()
|
||||
.paperlessUser
|
||||
.canCreateDocumentTypes,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -173,8 +174,10 @@ class _DocumentFilterFormState extends State<DocumentFilterForm> {
|
||||
initialValue: widget.initialFilter.correspondent,
|
||||
prefixIcon: const Icon(Icons.person_outline),
|
||||
allowSelectUnassigned: false,
|
||||
canCreateNewLabel:
|
||||
LocalUserAccount.current.paperlessUser.canCreateCorrespondents,
|
||||
canCreateNewLabel: context
|
||||
.watch<LocalUserAccount>()
|
||||
.paperlessUser
|
||||
.canCreateCorrespondents,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -187,7 +190,7 @@ class _DocumentFilterFormState extends State<DocumentFilterForm> {
|
||||
prefixIcon: const Icon(Icons.folder_outlined),
|
||||
allowSelectUnassigned: false,
|
||||
canCreateNewLabel:
|
||||
LocalUserAccount.current.paperlessUser.canCreateStoragePaths,
|
||||
context.watch<LocalUserAccount>().paperlessUser.canCreateStoragePaths,
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -80,6 +80,7 @@ class _DocumentFilterPanelState extends State<DocumentFilterPanel> {
|
||||
floatingActionButton: Visibility(
|
||||
visible: MediaQuery.of(context).viewInsets.bottom == 0,
|
||||
child: FloatingActionButton.extended(
|
||||
heroTag: "fab_document_filter_panel",
|
||||
icon: const Icon(Icons.done),
|
||||
label: Text(S.of(context)!.apply),
|
||||
onPressed: _onApplyFilter,
|
||||
|
||||
@@ -16,7 +16,7 @@ class ConfirmDeleteSavedViewDialog extends StatelessWidget {
|
||||
Widget build(BuildContext context) {
|
||||
return AlertDialog(
|
||||
title: Text(
|
||||
S.of(context)!.deleteView + view.name + "?",
|
||||
S.of(context)!.deleteView(view.name),
|
||||
softWrap: true,
|
||||
),
|
||||
content: Text(S.of(context)!.doYouReallyWantToDeleteThisView),
|
||||
|
||||
+17
-8
@@ -1,12 +1,12 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_bloc/flutter_bloc.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/features/documents/cubit/documents_cubit.dart';
|
||||
import 'package:paperless_mobile/features/documents/view/widgets/selection/bulk_delete_confirmation_dialog.dart';
|
||||
import 'package:paperless_mobile/generated/l10n/app_localizations.dart';
|
||||
import 'package:paperless_mobile/helpers/message_helpers.dart';
|
||||
import 'package:paperless_mobile/routes/typed/branches/documents_route.dart';
|
||||
|
||||
class DocumentSelectionSliverAppBar extends StatelessWidget {
|
||||
final DocumentsState state;
|
||||
@@ -65,24 +65,30 @@ class DocumentSelectionSliverAppBar extends StatelessWidget {
|
||||
label: Text(S.of(context)!.correspondent),
|
||||
avatar: const Icon(Icons.edit),
|
||||
onPressed: () {
|
||||
pushBulkEditCorrespondentRoute(context,
|
||||
selection: state.selection);
|
||||
BulkEditDocumentsRoute(BulkEditExtraWrapper(
|
||||
state.selection,
|
||||
LabelType.correspondent,
|
||||
)).push(context);
|
||||
},
|
||||
).paddedOnly(left: 8, right: 4),
|
||||
ActionChip(
|
||||
label: Text(S.of(context)!.documentType),
|
||||
avatar: const Icon(Icons.edit),
|
||||
onPressed: () async {
|
||||
pushBulkEditDocumentTypeRoute(context,
|
||||
selection: state.selection);
|
||||
BulkEditDocumentsRoute(BulkEditExtraWrapper(
|
||||
state.selection,
|
||||
LabelType.documentType,
|
||||
)).push(context);
|
||||
},
|
||||
).paddedOnly(left: 8, right: 4),
|
||||
ActionChip(
|
||||
label: Text(S.of(context)!.storagePath),
|
||||
avatar: const Icon(Icons.edit),
|
||||
onPressed: () async {
|
||||
pushBulkEditStoragePathRoute(context,
|
||||
selection: state.selection);
|
||||
BulkEditDocumentsRoute(BulkEditExtraWrapper(
|
||||
state.selection,
|
||||
LabelType.storagePath,
|
||||
)).push(context);
|
||||
},
|
||||
).paddedOnly(left: 8, right: 4),
|
||||
_buildBulkEditTagsChip(context).paddedOnly(left: 4, right: 4),
|
||||
@@ -98,7 +104,10 @@ class DocumentSelectionSliverAppBar extends StatelessWidget {
|
||||
label: Text(S.of(context)!.tags),
|
||||
avatar: const Icon(Icons.edit),
|
||||
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/view/widgets/search/sort_field_selection_bottom_sheet.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 {
|
||||
final bool enabled;
|
||||
@@ -20,55 +21,65 @@ class SortDocumentsButton extends StatelessWidget {
|
||||
if (state.filter.sortField == null) {
|
||||
return const SizedBox.shrink();
|
||||
}
|
||||
print(state.filter.sortField);
|
||||
return TextButton.icon(
|
||||
icon: Icon(state.filter.sortOrder == SortOrder.ascending
|
||||
? Icons.arrow_upward
|
||||
: Icons.arrow_downward),
|
||||
label: Text(translateSortField(context, state.filter.sortField)),
|
||||
onPressed: enabled
|
||||
? () {
|
||||
showModalBottomSheet(
|
||||
elevation: 2,
|
||||
context: context,
|
||||
isScrollControlled: true,
|
||||
shape: const RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.only(
|
||||
topLeft: Radius.circular(16),
|
||||
topRight: Radius.circular(16),
|
||||
),
|
||||
),
|
||||
builder: (_) => BlocProvider<DocumentsCubit>.value(
|
||||
value: context.read<DocumentsCubit>(),
|
||||
child: MultiBlocProvider(
|
||||
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,
|
||||
final icon = Icon(state.filter.sortOrder == SortOrder.ascending
|
||||
? Icons.arrow_upward
|
||||
: Icons.arrow_downward);
|
||||
final label = Text(translateSortField(context, state.filter.sortField));
|
||||
return ConnectivityAwareActionWrapper(
|
||||
offlineBuilder: (context, child) {
|
||||
return TextButton.icon(
|
||||
icon: icon,
|
||||
label: label,
|
||||
onPressed: null,
|
||||
);
|
||||
},
|
||||
child: TextButton.icon(
|
||||
icon: icon,
|
||||
label: label,
|
||||
onPressed: enabled
|
||||
? () {
|
||||
showModalBottomSheet(
|
||||
elevation: 2,
|
||||
context: context,
|
||||
isScrollControlled: true,
|
||||
shape: const RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.only(
|
||||
topLeft: Radius.circular(16),
|
||||
topRight: Radius.circular(16),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
: null,
|
||||
builder: (_) => BlocProvider<DocumentsCubit>.value(
|
||||
value: context.read<DocumentsCubit>(),
|
||||
child: MultiBlocProvider(
|
||||
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_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_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_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/view/label_form.dart';
|
||||
import 'package:paperless_mobile/generated/l10n/app_localizations.dart';
|
||||
|
||||
import 'package:paperless_mobile/helpers/message_helpers.dart';
|
||||
|
||||
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<void> Function(BuildContext context, T label) onDelete;
|
||||
final bool canDelete;
|
||||
final _formKey = GlobalKey<FormBuilderState>();
|
||||
|
||||
const EditLabelForm({
|
||||
EditLabelForm({
|
||||
super.key,
|
||||
required this.label,
|
||||
required this.fromJsonT,
|
||||
@@ -68,26 +71,32 @@ class EditLabelForm<T extends Label> extends StatelessWidget {
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Scaffold(
|
||||
appBar: AppBar(
|
||||
title: Text(S.of(context)!.edit),
|
||||
actions: [
|
||||
IconButton(
|
||||
onPressed: canDelete ? () => _onDelete(context) : null,
|
||||
icon: const Icon(Icons.delete),
|
||||
),
|
||||
],
|
||||
),
|
||||
body: LabelForm<T>(
|
||||
autofocusNameField: false,
|
||||
initialValue: label,
|
||||
fromJsonT: fromJsonT,
|
||||
submitButtonConfig: SubmitButtonConfig<T>(
|
||||
icon: const Icon(Icons.save),
|
||||
label: Text(S.of(context)!.saveChanges),
|
||||
onSubmit: (label) => onSubmit(context, label),
|
||||
return PopWithUnsavedChanges(
|
||||
hasChangesPredicate: () {
|
||||
return _formKey.currentState?.isDirty ?? false;
|
||||
},
|
||||
child: Scaffold(
|
||||
appBar: AppBar(
|
||||
title: Text(S.of(context)!.edit),
|
||||
actions: [
|
||||
IconButton(
|
||||
onPressed: canDelete ? () => _onDelete(context) : null,
|
||||
icon: const Icon(Icons.delete),
|
||||
),
|
||||
],
|
||||
),
|
||||
body: LabelForm<T>(
|
||||
formKey: _formKey,
|
||||
autofocusNameField: false,
|
||||
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) {
|
||||
log("An error occurred!", error: error, stackTrace: stackTrace);
|
||||
}
|
||||
Navigator.pop(context);
|
||||
context.pop();
|
||||
}
|
||||
} else {
|
||||
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';
|
||||
|
||||
class AddStoragePathPage extends StatelessWidget {
|
||||
final String? initalName;
|
||||
const AddStoragePathPage({Key? key, this.initalName}) : super(key: key);
|
||||
final String? initialName;
|
||||
const AddStoragePathPage({Key? key, this.initialName}) : super(key: key);
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
@@ -19,7 +19,7 @@ class AddStoragePathPage extends StatelessWidget {
|
||||
child: AddLabelPage<StoragePath>(
|
||||
pageTitle: Text(S.of(context)!.addStoragePath),
|
||||
fromJsonT: StoragePath.fromJson,
|
||||
initialName: initalName,
|
||||
initialName: initialName,
|
||||
onSubmit: (context, label) =>
|
||||
context.read<EditLabelCubit>().addStoragePath(label),
|
||||
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';
|
||||
|
||||
class AddTagPage extends StatelessWidget {
|
||||
final String? initialValue;
|
||||
const AddTagPage({Key? key, this.initialValue}) : super(key: key);
|
||||
final String? initialName;
|
||||
const AddTagPage({Key? key, this.initialName}) : super(key: key);
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
@@ -22,7 +22,7 @@ class AddTagPage extends StatelessWidget {
|
||||
child: AddLabelPage<Tag>(
|
||||
pageTitle: Text(S.of(context)!.addTag),
|
||||
fromJsonT: Tag.fromJson,
|
||||
initialName: initialValue,
|
||||
initialName: initialName,
|
||||
onSubmit: (context, label) =>
|
||||
context.read<EditLabelCubit>().addTag(label),
|
||||
additionalFields: [
|
||||
@@ -37,9 +37,16 @@ class AddTagPage extends StatelessWidget {
|
||||
.withOpacity(1.0),
|
||||
readOnly: true,
|
||||
),
|
||||
FormBuilderCheckbox(
|
||||
FormBuilderField<bool>(
|
||||
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),
|
||||
onDelete: (context, label) =>
|
||||
context.read<EditLabelCubit>().removeCorrespondent(label),
|
||||
canDelete:
|
||||
LocalUserAccount.current.paperlessUser.canDeleteCorrespondents,
|
||||
canDelete: context
|
||||
.watch<LocalUserAccount>()
|
||||
.paperlessUser
|
||||
.canDeleteCorrespondents,
|
||||
);
|
||||
}),
|
||||
);
|
||||
|
||||
@@ -22,8 +22,10 @@ class EditDocumentTypePage extends StatelessWidget {
|
||||
context.read<EditLabelCubit>().replaceDocumentType(label),
|
||||
onDelete: (context, label) =>
|
||||
context.read<EditLabelCubit>().removeDocumentType(label),
|
||||
canDelete:
|
||||
LocalUserAccount.current.paperlessUser.canDeleteDocumentTypes,
|
||||
canDelete: context
|
||||
.watch<LocalUserAccount>()
|
||||
.paperlessUser
|
||||
.canDeleteDocumentTypes,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -23,7 +23,10 @@ class EditStoragePathPage extends StatelessWidget {
|
||||
context.read<EditLabelCubit>().replaceStoragePath(label),
|
||||
onDelete: (context, label) =>
|
||||
context.read<EditLabelCubit>().removeStoragePath(label),
|
||||
canDelete: LocalUserAccount.current.paperlessUser.canDeleteStoragePaths,
|
||||
canDelete: context
|
||||
.watch<LocalUserAccount>()
|
||||
.paperlessUser
|
||||
.canDeleteStoragePaths,
|
||||
additionalFields: [
|
||||
StoragePathAutofillFormBuilderField(
|
||||
name: StoragePath.pathKey,
|
||||
|
||||
@@ -26,7 +26,8 @@ class EditTagPage extends StatelessWidget {
|
||||
context.read<EditLabelCubit>().replaceTag(label),
|
||||
onDelete: (context, label) =>
|
||||
context.read<EditLabelCubit>().removeTag(label),
|
||||
canDelete: LocalUserAccount.current.paperlessUser.canDeleteTags,
|
||||
canDelete:
|
||||
context.watch<LocalUserAccount>().paperlessUser.canDeleteTags,
|
||||
additionalFields: [
|
||||
FormBuilderColorPickerField(
|
||||
initialValue: tag.color,
|
||||
@@ -37,10 +38,16 @@ class EditTagPage extends StatelessWidget {
|
||||
colorPickerType: ColorPickerType.materialPicker,
|
||||
readOnly: true,
|
||||
),
|
||||
FormBuilderCheckbox(
|
||||
initialValue: tag.isInboxTag,
|
||||
FormBuilderField<bool>(
|
||||
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),
|
||||
);
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
|
||||
@@ -1,13 +1,12 @@
|
||||
import 'package:flutter/material.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_mobile/core/database/tables/local_user_account.dart';
|
||||
import 'package:paperless_mobile/core/translation/matching_algorithm_localization_mapper.dart';
|
||||
import 'package:paperless_mobile/extensions/flutter_extensions.dart';
|
||||
import 'package:paperless_mobile/features/home/view/model/api_version.dart';
|
||||
import 'package:paperless_mobile/generated/l10n/app_localizations.dart';
|
||||
|
||||
import 'package:paperless_mobile/helpers/message_helpers.dart';
|
||||
|
||||
class SubmitButtonConfig<T extends Label> {
|
||||
@@ -34,6 +33,7 @@ class LabelForm<T extends Label> extends StatefulWidget {
|
||||
final List<Widget> additionalFields;
|
||||
|
||||
final bool autofocusNameField;
|
||||
final GlobalKey<FormBuilderState>? formKey;
|
||||
|
||||
const LabelForm({
|
||||
Key? key,
|
||||
@@ -42,6 +42,7 @@ class LabelForm<T extends Label> extends StatefulWidget {
|
||||
this.additionalFields = const [],
|
||||
required this.submitButtonConfig,
|
||||
required this.autofocusNameField,
|
||||
this.formKey,
|
||||
}) : super(key: key);
|
||||
|
||||
@override
|
||||
@@ -49,7 +50,7 @@ class LabelForm<T extends Label> extends StatefulWidget {
|
||||
}
|
||||
|
||||
class _LabelFormState<T extends Label> extends State<LabelForm<T>> {
|
||||
final _formKey = GlobalKey<FormBuilderState>();
|
||||
late final GlobalKey<FormBuilderState> _formKey;
|
||||
|
||||
late bool _enableMatchFormField;
|
||||
|
||||
@@ -58,6 +59,7 @@ class _LabelFormState<T extends Label> extends State<LabelForm<T>> {
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_formKey = widget.formKey ?? GlobalKey<FormBuilderState>();
|
||||
var matchingAlgorithm = (widget.initialValue?.matchingAlgorithm ??
|
||||
MatchingAlgorithm.defaultValue);
|
||||
_enableMatchFormField = matchingAlgorithm != MatchingAlgorithm.auto &&
|
||||
@@ -68,11 +70,12 @@ class _LabelFormState<T extends Label> extends State<LabelForm<T>> {
|
||||
Widget build(BuildContext context) {
|
||||
List<MatchingAlgorithm> selectableMatchingAlgorithmValues =
|
||||
getSelectableMatchingAlgorithmValues(
|
||||
context.watch<ApiVersion>().hasMultiUserSupport,
|
||||
context.watch<LocalUserAccount>().hasMultiUserSupport,
|
||||
);
|
||||
return Scaffold(
|
||||
resizeToAvoidBottomInset: false,
|
||||
floatingActionButton: FloatingActionButton.extended(
|
||||
heroTag: "fab_label_form",
|
||||
icon: widget.submitButtonConfig.icon,
|
||||
label: widget.submitButtonConfig.label,
|
||||
onPressed: _onSubmit,
|
||||
@@ -134,10 +137,16 @@ class _LabelFormState<T extends Label> extends State<LabelForm<T>> {
|
||||
initialValue: widget.initialValue?.match,
|
||||
onChanged: (val) => setState(() => _errors = {}),
|
||||
),
|
||||
FormBuilderCheckbox(
|
||||
FormBuilderField<bool>(
|
||||
name: Label.isInsensitiveKey,
|
||||
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,
|
||||
].padded(),
|
||||
@@ -167,7 +176,7 @@ class _LabelFormState<T extends Label> extends State<LabelForm<T>> {
|
||||
};
|
||||
final parsed = widget.fromJsonT(mergedJson);
|
||||
final createdLabel = await widget.submitButtonConfig.onSubmit(parsed);
|
||||
Navigator.pop(context, createdLabel);
|
||||
context.pop(createdLabel);
|
||||
} on PaperlessApiException catch (error, stackTrace) {
|
||||
showErrorMessage(context, error, stackTrace);
|
||||
} on PaperlessFormValidationException catch (exception) {
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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 {
|
||||
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();
|
||||
}
|
||||
}
|
||||
@@ -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/repository/label_repository.dart';
|
||||
import 'package:paperless_mobile/core/repository/label_repository_state.dart';
|
||||
import 'package:paperless_mobile/core/service/connectivity_status_service.dart';
|
||||
import 'package:paperless_mobile/features/paged_document_view/cubit/paged_documents_state.dart';
|
||||
import 'package:paperless_mobile/features/paged_document_view/cubit/document_paging_bloc_mixin.dart';
|
||||
|
||||
@@ -19,6 +20,9 @@ class InboxCubit extends HydratedCubit<InboxState>
|
||||
|
||||
final PaperlessDocumentsApi _documentsApi;
|
||||
|
||||
@override
|
||||
final ConnectivityStatusService connectivityStatusService;
|
||||
|
||||
@override
|
||||
final DocumentChangedNotifier notifier;
|
||||
|
||||
@@ -32,21 +36,35 @@ class InboxCubit extends HydratedCubit<InboxState>
|
||||
this._statsApi,
|
||||
this._labelRepository,
|
||||
this.notifier,
|
||||
) : super(InboxState(
|
||||
labels: _labelRepository.state,
|
||||
)) {
|
||||
this.connectivityStatusService,
|
||||
) : super(InboxState(labels: _labelRepository.state)) {
|
||||
notifier.addListener(
|
||||
this,
|
||||
onDeleted: remove,
|
||||
onUpdated: (document) {
|
||||
if (document.tags
|
||||
final hasInboxTag = document.tags
|
||||
.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);
|
||||
emit(state.copyWith(itemsInInboxCount: state.itemsInInboxCount - 1));
|
||||
} else {
|
||||
replace(document);
|
||||
} else if (hasInboxTag) {
|
||||
if (wasInInboxBeforeUpdate) {
|
||||
print(
|
||||
"INBOX: Replacing document: has: $hasInboxTag, had: $wasInInboxBeforeUpdate");
|
||||
replace(document);
|
||||
} else {
|
||||
print(
|
||||
"INBOX: Adding document: has: $hasInboxTag, had: $wasInInboxBeforeUpdate");
|
||||
_addDocument(document);
|
||||
emit(
|
||||
state.copyWith(itemsInInboxCount: state.itemsInInboxCount + 1));
|
||||
}
|
||||
}
|
||||
},
|
||||
);
|
||||
@@ -58,22 +76,20 @@ class InboxCubit extends HydratedCubit<InboxState>
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> initialize() async {
|
||||
await refreshItemsInInboxCount(false);
|
||||
await loadInbox();
|
||||
}
|
||||
|
||||
Future<void> refreshItemsInInboxCount([bool shouldLoadInbox = true]) async {
|
||||
debugPrint("Checking for new items in inbox...");
|
||||
final stats = await _statsApi.getServerStatistics();
|
||||
|
||||
if (stats.documentsInInbox != state.itemsInInboxCount && shouldLoadInbox) {
|
||||
await loadInbox();
|
||||
}
|
||||
emit(
|
||||
state.copyWith(
|
||||
itemsInInboxCount: stats.documentsInInbox,
|
||||
),
|
||||
);
|
||||
emit(state.copyWith(itemsInInboxCount: stats.documentsInInbox));
|
||||
}
|
||||
|
||||
///
|
||||
@@ -82,7 +98,6 @@ class InboxCubit extends HydratedCubit<InboxState>
|
||||
Future<void> loadInbox() async {
|
||||
if (!isClosed) {
|
||||
debugPrint("Initializing inbox...");
|
||||
|
||||
final inboxTags = await _labelRepository.findAllTags().then(
|
||||
(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).
|
||||
///
|
||||
Future<void> reloadInbox() async {
|
||||
emit(state.copyWith(hasLoaded: false, isLoading: true));
|
||||
final inboxTags = await _labelRepository.findAllTags().then(
|
||||
(tags) => tags.where((t) => t.isInboxTag).map((t) => t.id!),
|
||||
);
|
||||
@@ -131,6 +157,7 @@ class InboxCubit extends HydratedCubit<InboxState>
|
||||
}
|
||||
emit(state.copyWith(inboxTags: inboxTags));
|
||||
updateFilter(
|
||||
emitLoading: false,
|
||||
filter: DocumentFilter(
|
||||
sortField: SortField.added,
|
||||
tags: TagsQuery.ids(include: inboxTags.toList()),
|
||||
@@ -151,7 +178,7 @@ class InboxCubit extends HydratedCubit<InboxState>
|
||||
document.copyWith(tags: updatedTags),
|
||||
);
|
||||
// Remove first so document is not replaced first.
|
||||
remove(document);
|
||||
// remove(document);
|
||||
notifier.notifyUpdated(updatedDocument);
|
||||
return tagsToRemove;
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@ 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/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_confirm_button.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/paged_document_view/view/document_paging_view_mixin.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';
|
||||
|
||||
class InboxPage extends StatefulWidget {
|
||||
@@ -33,42 +35,99 @@ class _InboxPageState extends State<InboxPage>
|
||||
|
||||
@override
|
||||
final pagingScrollController = ScrollController();
|
||||
final _nestedScrollViewKey = GlobalKey<NestedScrollViewState>();
|
||||
final _emptyStateRefreshIndicatorKey = GlobalKey<RefreshIndicatorState>();
|
||||
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
|
||||
Widget build(BuildContext context) {
|
||||
final canEditDocument =
|
||||
LocalUserAccount.current.paperlessUser.canEditDocuments;
|
||||
context.watch<LocalUserAccount>().paperlessUser.canEditDocuments;
|
||||
return Scaffold(
|
||||
drawer: const AppDrawer(),
|
||||
floatingActionButton: BlocBuilder<InboxCubit, InboxState>(
|
||||
builder: (context, state) {
|
||||
if (!state.hasLoaded || state.documents.isEmpty || !canEditDocument) {
|
||||
return const SizedBox.shrink();
|
||||
}
|
||||
return FloatingActionButton.extended(
|
||||
label: Text(S.of(context)!.allSeen),
|
||||
icon: const Icon(Icons.done_all),
|
||||
onPressed: state.hasLoaded && state.documents.isNotEmpty
|
||||
? () => _onMarkAllAsSeen(
|
||||
state.documents,
|
||||
state.inboxTags,
|
||||
)
|
||||
: null,
|
||||
);
|
||||
},
|
||||
floatingActionButton: ConnectivityAwareActionWrapper(
|
||||
offlineBuilder: (context, child) => const SizedBox.shrink(),
|
||||
child: BlocBuilder<InboxCubit, InboxState>(
|
||||
builder: (context, state) {
|
||||
if (!state.hasLoaded ||
|
||||
state.documents.isEmpty ||
|
||||
!canEditDocument) {
|
||||
return const SizedBox.shrink();
|
||||
}
|
||||
return FloatingActionButton.extended(
|
||||
extendedPadding: _showExtendedFab
|
||||
? null
|
||||
: const EdgeInsets.symmetric(horizontal: 16),
|
||||
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(
|
||||
top: true,
|
||||
child: NestedScrollView(
|
||||
key: _nestedScrollViewKey,
|
||||
headerSliverBuilder: (context, innerBoxIsScrolled) => [
|
||||
SliverOverlapAbsorber(
|
||||
handle: searchBarHandle,
|
||||
sliver: SliverSearchBar(
|
||||
titleText: S.of(context)!.inbox,
|
||||
),
|
||||
)
|
||||
SliverSearchBar(titleText: S.of(context)!.inbox),
|
||||
],
|
||||
body: BlocBuilder<InboxCubit, InboxState>(
|
||||
builder: (_, state) {
|
||||
@@ -213,6 +272,16 @@ class _InboxPageState extends State<InboxPage>
|
||||
}
|
||||
|
||||
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 {
|
||||
final removedTags = await context.read<InboxCubit>().removeFromInbox(doc);
|
||||
showSnackBar(
|
||||
|
||||
@@ -3,7 +3,6 @@ import 'package:flutter/material.dart';
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
import 'package:paperless_api/paperless_api.dart';
|
||||
import 'package:paperless_mobile/core/database/tables/local_user_account.dart';
|
||||
import 'package:paperless_mobile/core/navigation/push_routes.dart';
|
||||
import 'package:paperless_mobile/core/widgets/shimmer_placeholder.dart';
|
||||
import 'package:paperless_mobile/core/workarounds/colored_chip.dart';
|
||||
import 'package:paperless_mobile/extensions/flutter_extensions.dart';
|
||||
@@ -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/view/widgets/label_text.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 {
|
||||
const InboxItemPlaceholder({super.key});
|
||||
@@ -150,11 +151,10 @@ class _InboxItemState extends State<InboxItem> {
|
||||
return GestureDetector(
|
||||
behavior: HitTestBehavior.translucent,
|
||||
onTap: () {
|
||||
pushDocumentDetailsRoute(
|
||||
context,
|
||||
document: widget.document,
|
||||
DocumentDetailsRoute(
|
||||
$extra: widget.document,
|
||||
isLabelClickable: false,
|
||||
);
|
||||
).push(context);
|
||||
},
|
||||
child: SizedBox(
|
||||
height: 200,
|
||||
@@ -227,7 +227,9 @@ class _InboxItemState extends State<InboxItem> {
|
||||
),
|
||||
LimitedBox(
|
||||
maxHeight: 56,
|
||||
child: _buildActions(context),
|
||||
child: ConnectivityAwareActionWrapper(
|
||||
child: _buildActions(context),
|
||||
),
|
||||
),
|
||||
],
|
||||
).paddedOnly(left: 8, top: 8, bottom: 8),
|
||||
@@ -238,8 +240,9 @@ class _InboxItemState extends State<InboxItem> {
|
||||
}
|
||||
|
||||
Widget _buildActions(BuildContext context) {
|
||||
final canEdit = LocalUserAccount.current.paperlessUser.canEditDocuments;
|
||||
final canDelete = LocalUserAccount.current.paperlessUser.canDeleteDocuments;
|
||||
final currentUser = context.watch<LocalUserAccount>().paperlessUser;
|
||||
final canEdit = currentUser.canEditDocuments;
|
||||
final canDelete = currentUser.canDeleteDocuments;
|
||||
final chipShape = RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(32),
|
||||
);
|
||||
|
||||
@@ -4,8 +4,8 @@ import 'package:paperless_api/paperless_api.dart';
|
||||
import 'package:paperless_mobile/core/repository/label_repository.dart';
|
||||
import 'package:paperless_mobile/features/labels/cubit/label_cubit_mixin.dart';
|
||||
|
||||
part 'label_state.dart';
|
||||
part 'label_cubit.freezed.dart';
|
||||
part 'label_state.dart';
|
||||
|
||||
class LabelCubit extends Cubit<LabelState> with LabelCubitMixin<LabelState> {
|
||||
@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
|
||||
Future<void> close() {
|
||||
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: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/generated/l10n/app_localizations.dart';
|
||||
|
||||
@@ -68,10 +69,12 @@ class _FullscreenTagsFormState extends State<FullscreenTagsForm> {
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final showFab = MediaQuery.viewInsetsOf(context).bottom == 0;
|
||||
final theme = Theme.of(context);
|
||||
return Scaffold(
|
||||
floatingActionButton: widget.allowCreation
|
||||
floatingActionButton: widget.allowCreation && showFab
|
||||
? FloatingActionButton(
|
||||
heroTag: "fab_tags_form",
|
||||
onPressed: _onAddTag,
|
||||
child: const Icon(Icons.add),
|
||||
)
|
||||
@@ -191,7 +194,7 @@ class _FullscreenTagsFormState extends State<FullscreenTagsForm> {
|
||||
final createdTag = await Navigator.of(context).push<Tag?>(
|
||||
MaterialPageRoute(
|
||||
builder: (context) => AddTagPage(
|
||||
initialValue: _textEditingController.text,
|
||||
initialName: _textEditingController.text,
|
||||
),
|
||||
),
|
||||
);
|
||||
@@ -237,10 +240,16 @@ class _FullscreenTagsFormState extends State<FullscreenTagsForm> {
|
||||
var matches = _options
|
||||
.where((e) => e.name.trim().toLowerCase().contains(normalizedQuery));
|
||||
if (matches.isEmpty && widget.allowCreation) {
|
||||
yield Text(S.of(context)!.noItemsFound);
|
||||
yield TextButton(
|
||||
child: Text(S.of(context)!.addTag),
|
||||
onPressed: _onAddTag,
|
||||
yield Center(
|
||||
child: Column(
|
||||
children: [
|
||||
Text(S.of(context)!.noItemsFound).padded(),
|
||||
TextButton(
|
||||
child: Text(S.of(context)!.addTag),
|
||||
onPressed: _onAddTag,
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
for (final tag in matches) {
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import 'package:animations/animations.dart';
|
||||
import 'package:collection/collection.dart';
|
||||
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/database/tables/local_user_account.dart';
|
||||
@@ -73,7 +74,7 @@ class TagsFormField extends StatelessWidget {
|
||||
initialValue: field.value,
|
||||
allowOnlySelection: allowOnlySelection,
|
||||
allowCreation: allowCreation &&
|
||||
LocalUserAccount.current.paperlessUser.canCreateTags,
|
||||
context.watch<LocalUserAccount>().paperlessUser.canCreateTags,
|
||||
allowExclude: allowExclude,
|
||||
),
|
||||
onClosed: (data) {
|
||||
|
||||
@@ -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/local_user_account.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/features/app_drawer/view/app_drawer.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/view/widgets/label_tab_view.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 {
|
||||
const LabelsPage({Key? key}) : super(key: key);
|
||||
@@ -40,6 +31,7 @@ class _LabelsPageState extends State<LabelsPage>
|
||||
SliverOverlapAbsorberHandle();
|
||||
|
||||
late final TabController _tabController;
|
||||
|
||||
int _currentIndex = 0;
|
||||
|
||||
int _calculateTabCount(UserModel user) => [
|
||||
@@ -52,12 +44,18 @@ class _LabelsPageState extends State<LabelsPage>
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
final user = LocalUserAccount.current.paperlessUser;
|
||||
final user = context.read<LocalUserAccount>().paperlessUser;
|
||||
_tabController = TabController(
|
||||
length: _calculateTabCount(user), vsync: this)
|
||||
..addListener(() => setState(() => _currentIndex = _tabController.index));
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_tabController.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return ValueListenableBuilder(
|
||||
@@ -67,22 +65,39 @@ class _LabelsPageState extends State<LabelsPage>
|
||||
final currentUserId =
|
||||
Hive.box<GlobalSettings>(HiveBoxes.globalSettings)
|
||||
.getValue()!
|
||||
.currentLoggedInUser;
|
||||
.loggedInUserId;
|
||||
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>(
|
||||
builder: (context, connectedState) {
|
||||
return SafeArea(
|
||||
child: Scaffold(
|
||||
drawer: const AppDrawer(),
|
||||
floatingActionButton: FloatingActionButton(
|
||||
onPressed: [
|
||||
if (user.canViewCorrespondents) _openAddCorrespondentPage,
|
||||
if (user.canViewDocumentTypes) _openAddDocumentTypePage,
|
||||
if (user.canViewTags) _openAddTagPage,
|
||||
if (user.canViewStoragePaths) _openAddStoragePathPage,
|
||||
][_currentIndex],
|
||||
child: const Icon(Icons.add),
|
||||
floatingActionButton: ConnectivityAwareActionWrapper(
|
||||
offlineBuilder: (context, child) => const SizedBox.shrink(),
|
||||
child: FloatingActionButton.extended(
|
||||
heroTag: "inbox_page_fab",
|
||||
label: Text(fabLabel),
|
||||
icon: Icon(Icons.add),
|
||||
onPressed: [
|
||||
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(
|
||||
floatHeaderSlivers: true,
|
||||
@@ -213,144 +228,13 @@ class _LabelsPageState extends State<LabelsPage>
|
||||
controller: _tabController,
|
||||
children: [
|
||||
if (user.canViewCorrespondents)
|
||||
Builder(
|
||||
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,
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
),
|
||||
_buildCorrespondentsView(state, user),
|
||||
if (user.canViewDocumentTypes)
|
||||
Builder(
|
||||
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,
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
),
|
||||
_buildDocumentTypesView(state, user),
|
||||
if (user.canViewTags)
|
||||
Builder(
|
||||
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,
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
),
|
||||
_buildTagsView(state, user),
|
||||
if (user.canViewStoragePaths)
|
||||
Builder(
|
||||
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,
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
),
|
||||
_buildStoragePathView(state, user),
|
||||
],
|
||||
),
|
||||
),
|
||||
@@ -365,73 +249,124 @@ class _LabelsPageState extends State<LabelsPage>
|
||||
});
|
||||
}
|
||||
|
||||
void _openEditCorrespondentPage(Correspondent correspondent) {
|
||||
Navigator.push(
|
||||
context,
|
||||
_buildLabelPageRoute(EditCorrespondentPage(correspondent: correspondent)),
|
||||
Widget _buildCorrespondentsView(LabelState state, UserModel user) {
|
||||
return Builder(
|
||||
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: (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) {
|
||||
Navigator.push(
|
||||
context,
|
||||
_buildLabelPageRoute(EditDocumentTypePage(documentType: docType)),
|
||||
Widget _buildDocumentTypesView(LabelState state, UserModel user) {
|
||||
return Builder(
|
||||
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: (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) {
|
||||
Navigator.push(
|
||||
context,
|
||||
_buildLabelPageRoute(EditTagPage(tag: tag)),
|
||||
Widget _buildTagsView(LabelState state, UserModel user) {
|
||||
return Builder(
|
||||
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: (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) {
|
||||
Navigator.push(
|
||||
context,
|
||||
_buildLabelPageRoute(EditStoragePathPage(
|
||||
storagePath: path,
|
||||
)),
|
||||
);
|
||||
}
|
||||
|
||||
void _openAddCorrespondentPage() {
|
||||
Navigator.push(
|
||||
context,
|
||||
_buildLabelPageRoute(const AddCorrespondentPage()),
|
||||
);
|
||||
}
|
||||
|
||||
void _openAddDocumentTypePage() {
|
||||
Navigator.push(
|
||||
context,
|
||||
_buildLabelPageRoute(const AddDocumentTypePage()),
|
||||
);
|
||||
}
|
||||
|
||||
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,
|
||||
),
|
||||
Widget _buildStoragePathView(LabelState state, UserModel user) {
|
||||
return Builder(
|
||||
builder: (context) {
|
||||
return CustomScrollView(
|
||||
slivers: [
|
||||
SliverOverlapInjector(handle: searchBarHandle),
|
||||
SliverOverlapInjector(handle: tabBarHandle),
|
||||
LabelTabView<StoragePath>(
|
||||
labels: state.storagePaths,
|
||||
onEdit: (label) {
|
||||
EditLabelRoute(label).push(context);
|
||||
},
|
||||
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: () =>
|
||||
CreateLabelRoute(LabelType.storagePath).push(context),
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -69,6 +69,7 @@ class _FullscreenLabelFormState<T extends Label>
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final showFab = MediaQuery.viewInsetsOf(context).bottom == 0;
|
||||
final theme = Theme.of(context);
|
||||
final options = _filterOptionsByQuery(_textEditingController.text);
|
||||
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(
|
||||
builder: (context) {
|
||||
return Column(
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
import 'package:paperless_api/paperless_api.dart';
|
||||
import 'package:paperless_mobile/core/database/tables/local_user_account.dart';
|
||||
import 'package:paperless_mobile/core/navigation/push_routes.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 {
|
||||
final T label;
|
||||
@@ -36,14 +37,14 @@ class LabelItem<T extends Label> extends StatelessWidget {
|
||||
|
||||
Widget _buildReferencedDocumentsWidget(BuildContext context) {
|
||||
final canOpen = (label.documentCount ?? 0) > 0 &&
|
||||
LocalUserAccount.current.paperlessUser.canViewDocuments;
|
||||
context.watch<LocalUserAccount>().paperlessUser.canViewDocuments;
|
||||
return TextButton.icon(
|
||||
label: const Icon(Icons.link),
|
||||
icon: Text(formatMaxCount(label.documentCount)),
|
||||
onPressed: canOpen
|
||||
? () {
|
||||
final filter = filterBuilder(label);
|
||||
pushLinkedDocumentsView(context, filter: filter);
|
||||
LinkedDocumentsRoute(filter).push(context);
|
||||
}
|
||||
: null,
|
||||
);
|
||||
|
||||
@@ -44,7 +44,7 @@ class LabelTabView<T extends Label> extends StatelessWidget {
|
||||
return BlocBuilder<ConnectivityCubit, ConnectivityState>(
|
||||
builder: (context, connectivityState) {
|
||||
if (!connectivityState.isConnected) {
|
||||
return const OfflineWidget();
|
||||
return const SliverFillRemaining(child: OfflineWidget());
|
||||
}
|
||||
final sortedLabels = labels.values.toList()..sort();
|
||||
if (labels.isEmpty) {
|
||||
@@ -76,9 +76,7 @@ class LabelTabView<T extends Label> extends StatelessWidget {
|
||||
Text(
|
||||
translateMatchingAlgorithmName(
|
||||
context, l.matchingAlgorithm) +
|
||||
((l.match?.isNotEmpty ?? false)
|
||||
? ": ${l.match}"
|
||||
: ""),
|
||||
(l.match.isNotEmpty ? ": ${l.match}" : ""),
|
||||
maxLines: 2,
|
||||
),
|
||||
onOpenEditPage: canEdit ? onEdit : null,
|
||||
|
||||
@@ -8,7 +8,7 @@ class LabelText<T extends Label> extends StatelessWidget {
|
||||
const LabelText({
|
||||
super.key,
|
||||
this.style,
|
||||
this.placeholder = "",
|
||||
this.placeholder = "-",
|
||||
required this.label,
|
||||
});
|
||||
|
||||
|
||||
@@ -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
Reference in New Issue
Block a user