Merge branch 'feature/go_router_migration' into development

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

After

Width:  |  Height:  |  Size: 963 B

+1
View File
@@ -1,3 +1,4 @@
project_id: "568557"
files: [
{
"source" : "/lib/l10n/intl_en.arb",
+39 -34
View File
@@ -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
-8
View File
@@ -1,8 +0,0 @@
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:paperless_mobile/core/model/document_processing_status.dart';
class DocumentStatusCubit extends Cubit<DocumentProcessingStatus?> {
DocumentStatusCubit() : super(null);
void updateStatus(DocumentProcessingStatus? status) => emit(status);
}
-2
View File
@@ -15,11 +15,9 @@ import 'package:paperless_mobile/features/settings/model/view_type.dart';
class HiveBoxes {
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';
}
+23 -2
View File
@@ -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)!;
}
+8 -1
View File
@@ -1 +1,8 @@
const supportedFileExtensions = ['pdf', 'png', 'tiff', 'gif', 'jpg', 'jpeg'];
const supportedFileExtensions = [
'.pdf',
'.png',
'.tiff',
'.gif',
'.jpg',
'.jpeg'
];
@@ -39,8 +39,6 @@ class DioHttpErrorInterceptor extends Interceptor {
),
);
}
} else {
return handler.next(err);
}
}
}
@@ -0,0 +1,12 @@
import 'package:paperless_api/paperless_api.dart';
class InfoMessageException implements Exception {
final ErrorCode code;
final String? message;
final StackTrace? stackTrace;
InfoMessageException({
required this.code,
this.message,
this.stackTrace,
});
}
-391
View File
@@ -1,391 +0,0 @@
import 'dart:typed_data';
import 'package:connectivity_plus/connectivity_plus.dart';
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:flutter_cache_manager/flutter_cache_manager.dart';
import 'package:hive/hive.dart';
import 'package:paperless_api/paperless_api.dart';
import 'package:paperless_mobile/core/bloc/connectivity_cubit.dart';
import 'package:paperless_mobile/core/config/hive/hive_config.dart';
import 'package:paperless_mobile/core/database/tables/global_settings.dart';
import 'package:paperless_mobile/core/database/tables/local_user_app_state.dart';
import 'package:paperless_mobile/core/notifier/document_changed_notifier.dart';
import 'package:paperless_mobile/core/repository/label_repository.dart';
import 'package:paperless_mobile/core/repository/user_repository.dart';
import 'package:paperless_mobile/features/document_bulk_action/cubit/document_bulk_action_cubit.dart';
import 'package:paperless_mobile/features/document_bulk_action/view/widgets/fullscreen_bulk_edit_label_page.dart';
import 'package:paperless_mobile/features/document_bulk_action/view/widgets/fullscreen_bulk_edit_tags_widget.dart';
import 'package:paperless_mobile/features/document_search/cubit/document_search_cubit.dart';
import 'package:paperless_mobile/features/document_search/view/document_search_page.dart';
import 'package:paperless_mobile/features/document_upload/cubit/document_upload_cubit.dart';
import 'package:paperless_mobile/features/document_upload/view/document_upload_preparation_page.dart';
import 'package:paperless_mobile/features/home/view/model/api_version.dart';
import 'package:paperless_mobile/features/linked_documents/cubit/linked_documents_cubit.dart';
import 'package:paperless_mobile/features/linked_documents/view/linked_documents_page.dart';
import 'package:paperless_mobile/features/notifications/services/local_notification_service.dart';
import 'package:paperless_mobile/features/saved_view/cubit/saved_view_cubit.dart';
import 'package:paperless_mobile/features/saved_view/view/add_saved_view_page.dart';
import 'package:paperless_mobile/features/saved_view_details/cubit/saved_view_details_cubit.dart';
import 'package:paperless_mobile/features/saved_view_details/view/saved_view_details_page.dart';
import 'package:paperless_mobile/generated/l10n/app_localizations.dart';
import 'package:paperless_mobile/routes/document_details_route.dart';
import 'package:provider/provider.dart';
// These are convenience methods for nativating to views without having to pass providers around explicitly.
// Providers unfortunately have to be passed to the routes since they are children of the Navigator, not ancestors.
Future<void> pushDocumentSearchPage(BuildContext context) {
final currentUser = Hive.box<GlobalSettings>(HiveBoxes.globalSettings)
.getValue()!
.currentLoggedInUser;
final userRepo = context.read<UserRepository>();
return Navigator.of(context).push(
MaterialPageRoute(
builder: (_) => MultiProvider(
providers: [
Provider.value(value: context.read<LabelRepository>()),
Provider.value(value: context.read<PaperlessDocumentsApi>()),
Provider.value(value: context.read<DocumentChangedNotifier>()),
Provider.value(value: context.read<CacheManager>()),
Provider.value(value: userRepo),
],
builder: (context, _) {
return BlocProvider(
create: (context) => DocumentSearchCubit(
context.read(),
context.read(),
Hive.box<LocalUserAppState>(HiveBoxes.localUserAppState)
.get(currentUser)!,
),
child: const DocumentSearchPage(),
);
},
),
),
);
}
Future<void> pushDocumentDetailsRoute(
BuildContext context, {
required DocumentModel document,
bool isLabelClickable = true,
bool allowEdit = true,
String? titleAndContentQueryString,
}) {
return Navigator.of(context).push(
MaterialPageRoute(
builder: (_) => MultiProvider(
providers: [
Provider.value(value: context.read<ApiVersion>()),
Provider.value(value: context.read<LabelRepository>()),
Provider.value(value: context.read<DocumentChangedNotifier>()),
Provider.value(value: context.read<PaperlessDocumentsApi>()),
Provider.value(value: context.read<LocalNotificationService>()),
Provider.value(value: context.read<CacheManager>()),
Provider.value(value: context.read<ConnectivityCubit>()),
if (context.read<ApiVersion>().hasMultiUserSupport)
Provider.value(value: context.read<UserRepository>()),
],
child: DocumentDetailsRoute(
document: document,
isLabelClickable: isLabelClickable,
),
),
),
);
}
Future<void> pushSavedViewDetailsRoute(
BuildContext context, {
required SavedView savedView,
}) {
final apiVersion = context.read<ApiVersion>();
return Navigator.of(context).push(
MaterialPageRoute(
builder: (_) => MultiProvider(
providers: [
Provider.value(value: apiVersion),
if (apiVersion.hasMultiUserSupport)
Provider.value(value: context.read<UserRepository>()),
Provider.value(value: context.read<LabelRepository>()),
Provider.value(value: context.read<DocumentChangedNotifier>()),
Provider.value(value: context.read<PaperlessDocumentsApi>()),
Provider.value(value: context.read<CacheManager>()),
Provider.value(value: context.read<ConnectivityCubit>()),
],
builder: (_, child) {
return BlocProvider(
create: (context) => SavedViewDetailsCubit(
context.read(),
context.read(),
context.read(),
LocalUserAppState.current,
savedView: savedView,
),
child: SavedViewDetailsPage(
onDelete: context.read<SavedViewCubit>().remove),
);
},
),
),
);
}
Future<SavedView?> pushAddSavedViewRoute(BuildContext context,
{required DocumentFilter filter}) {
return Navigator.of(context).push<SavedView?>(
MaterialPageRoute(
builder: (_) => AddSavedViewPage(
currentFilter: filter,
correspondents: context.read<LabelRepository>().state.correspondents,
documentTypes: context.read<LabelRepository>().state.documentTypes,
storagePaths: context.read<LabelRepository>().state.storagePaths,
tags: context.read<LabelRepository>().state.tags,
),
),
);
}
Future<void> pushLinkedDocumentsView(BuildContext context,
{required DocumentFilter filter}) {
return Navigator.push(
context,
MaterialPageRoute(
builder: (_) => MultiProvider(
providers: [
Provider.value(value: context.read<ApiVersion>()),
Provider.value(value: context.read<LabelRepository>()),
Provider.value(value: context.read<DocumentChangedNotifier>()),
Provider.value(value: context.read<PaperlessDocumentsApi>()),
Provider.value(value: context.read<LocalNotificationService>()),
Provider.value(value: context.read<CacheManager>()),
Provider.value(value: context.read<ConnectivityCubit>()),
if (context.read<ApiVersion>().hasMultiUserSupport)
Provider.value(value: context.read<UserRepository>()),
],
builder: (context, _) => BlocProvider(
create: (context) => LinkedDocumentsCubit(
filter,
context.read(),
context.read(),
context.read(),
),
child: const LinkedDocumentsPage(),
),
),
),
);
}
Future<void> pushBulkEditCorrespondentRoute(
BuildContext context, {
required List<DocumentModel> selection,
}) {
return Navigator.of(context).push(
MaterialPageRoute(
builder: (_) => MultiProvider(
providers: [
..._getRequiredBulkEditProviders(context),
],
builder: (_, __) => BlocProvider(
create: (_) => DocumentBulkActionCubit(
context.read(),
context.read(),
context.read(),
selection: selection,
),
child: BlocBuilder<DocumentBulkActionCubit, DocumentBulkActionState>(
builder: (context, state) {
return FullscreenBulkEditLabelPage(
options: state.correspondents,
selection: state.selection,
labelMapper: (document) => document.correspondent,
leadingIcon: const Icon(Icons.person_outline),
hintText: S.of(context)!.startTyping,
onSubmit: context
.read<DocumentBulkActionCubit>()
.bulkModifyCorrespondent,
assignMessageBuilder: (int count, String name) {
return S.of(context)!.bulkEditCorrespondentAssignMessage(
name,
count,
);
},
removeMessageBuilder: (int count) {
return S
.of(context)!
.bulkEditCorrespondentRemoveMessage(count);
},
);
},
),
),
),
),
);
}
Future<void> pushBulkEditStoragePathRoute(
BuildContext context, {
required List<DocumentModel> selection,
}) {
return Navigator.of(context).push(
MaterialPageRoute(
builder: (_) => MultiProvider(
providers: [
..._getRequiredBulkEditProviders(context),
],
builder: (_, __) => BlocProvider(
create: (_) => DocumentBulkActionCubit(
context.read(),
context.read(),
context.read(),
selection: selection,
),
child: BlocBuilder<DocumentBulkActionCubit, DocumentBulkActionState>(
builder: (context, state) {
return FullscreenBulkEditLabelPage(
options: state.storagePaths,
selection: state.selection,
labelMapper: (document) => document.storagePath,
leadingIcon: const Icon(Icons.folder_outlined),
hintText: S.of(context)!.startTyping,
onSubmit: context
.read<DocumentBulkActionCubit>()
.bulkModifyStoragePath,
assignMessageBuilder: (int count, String name) {
return S.of(context)!.bulkEditStoragePathAssignMessage(
count,
name,
);
},
removeMessageBuilder: (int count) {
return S.of(context)!.bulkEditStoragePathRemoveMessage(count);
},
);
},
),
),
),
),
);
}
Future<void> pushBulkEditTagsRoute(
BuildContext context, {
required List<DocumentModel> selection,
}) {
return Navigator.of(context).push(
MaterialPageRoute(
builder: (_) => MultiProvider(
providers: [
..._getRequiredBulkEditProviders(context),
],
builder: (_, __) => BlocProvider(
create: (_) => DocumentBulkActionCubit(
context.read(),
context.read(),
context.read(),
selection: selection,
),
child: Builder(builder: (context) {
return const FullscreenBulkEditTagsWidget();
}),
),
),
),
);
}
Future<void> pushBulkEditDocumentTypeRoute(BuildContext context,
{required List<DocumentModel> selection}) {
return Navigator.of(context).push(
MaterialPageRoute(
builder: (_) => MultiProvider(
providers: [
..._getRequiredBulkEditProviders(context),
],
builder: (_, __) => BlocProvider(
create: (_) => DocumentBulkActionCubit(
context.read(),
context.read(),
context.read(),
selection: selection,
),
child: BlocBuilder<DocumentBulkActionCubit, DocumentBulkActionState>(
builder: (context, state) {
return FullscreenBulkEditLabelPage(
options: state.documentTypes,
selection: state.selection,
labelMapper: (document) => document.documentType,
leadingIcon: const Icon(Icons.description_outlined),
hintText: S.of(context)!.startTyping,
onSubmit: context
.read<DocumentBulkActionCubit>()
.bulkModifyDocumentType,
assignMessageBuilder: (int count, String name) {
return S.of(context)!.bulkEditDocumentTypeAssignMessage(
count,
name,
);
},
removeMessageBuilder: (int count) {
return S
.of(context)!
.bulkEditDocumentTypeRemoveMessage(count);
},
);
},
),
),
),
),
);
}
Future<DocumentUploadResult?> pushDocumentUploadPreparationPage(
BuildContext context, {
required Uint8List bytes,
String? filename,
String? fileExtension,
String? title,
}) {
final labelRepo = context.read<LabelRepository>();
final docsApi = context.read<PaperlessDocumentsApi>();
final connectivity = context.read<Connectivity>();
final apiVersion = context.read<ApiVersion>();
return Navigator.of(context).push<DocumentUploadResult>(
MaterialPageRoute(
builder: (_) => MultiProvider(
providers: [
Provider.value(value: labelRepo),
Provider.value(value: docsApi),
Provider.value(value: connectivity),
Provider.value(value: apiVersion)
],
builder: (_, child) => BlocProvider(
create: (_) => DocumentUploadCubit(
context.read(),
context.read(),
context.read(),
),
child: DocumentUploadPreparationPage(
fileBytes: bytes,
fileExtension: fileExtension,
filename: filename,
title: title,
),
),
),
),
);
}
List<Provider> _getRequiredBulkEditProviders(BuildContext context) {
return [
Provider.value(value: context.read<PaperlessDocumentsApi>()),
Provider.value(value: context.read<LabelRepository>()),
Provider.value(value: context.read<DocumentChangedNotifier>()),
];
}
@@ -12,6 +12,10 @@ class DocumentChangedNotifier {
final Map<dynamic, List<StreamSubscription>> _subscribers = {};
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);
+2
View File
@@ -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;
}
}
-20
View File
@@ -1,20 +0,0 @@
class FileDescription {
final String filename;
final String extension;
FileDescription({
required this.filename,
required this.extension,
});
factory FileDescription.fromPath(String path) {
final filename = path.split(RegExp(r"/")).last;
final fragments = filename.split(".");
final ext = fragments.removeLast();
final name = fragments.join(".");
return FileDescription(
filename: name,
extension: ext,
);
}
}
+47 -39
View File
@@ -1,34 +1,30 @@
import 'dart:io';
import '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;
}
-103
View File
@@ -1,103 +0,0 @@
import 'dart:io';
import 'package:dio/dio.dart';
import 'package:paperless_mobile/core/database/tables/user_credentials.dart';
// import 'package:web_socket_channel/io.dart';
abstract class StatusService {
Future<void> startListeningBeforeDocumentUpload(
String httpUrl, UserCredentials credentials, String documentFileName);
}
class WebSocketStatusService implements StatusService {
late WebSocket? socket;
// late IOWebSocketChannel? _channel;
WebSocketStatusService();
@override
Future<void> startListeningBeforeDocumentUpload(
String httpUrl,
UserCredentials credentials,
String documentFileName,
) async {
// socket = await WebSocket.connect(
// httpUrl.replaceFirst("http", "ws") + "/ws/status/",
// customClient: getIt<HttpClient>(),
// headers: {
// 'Authorization': 'Token ${credentials.token}',
// },
// ).catchError((_) {
// // Use long polling if connection could not be established
// });
// if (socket != null) {
// socket!.where(isNotNull).listen((event) {
// final status = DocumentProcessingStatus.fromJson(event);
// getIt<DocumentStatusCubit>().updateStatus(status);
// if (status.currentProgress == 100) {
// socket!.close();
// }
// });
// }
}
}
class LongPollingStatusService implements StatusService {
final Dio client;
const LongPollingStatusService(this.client);
@override
Future<void> startListeningBeforeDocumentUpload(
String httpUrl,
UserCredentials credentials,
String documentFileName,
) async {
// final today = DateTime.now();
// bool consumptionFinished = false;
// int retryCount = 0;
// getIt<DocumentStatusCubit>().updateStatus(
// DocumentProcessingStatus(
// currentProgress: 0,
// filename: documentFileName,
// maxProgress: 100,
// message: ProcessingMessage.new_file,
// status: ProcessingStatus.working,
// taskId: DocumentProcessingStatus.unknownTaskId,
// documentId: null,
// isApproximated: true,
// ),
// );
// do {
// final response = await httpClient.get(
// Uri.parse(
// '$httpUrl/api/documents/?query=$documentFileName added:${formatDate(today)}'),
// );
// final data = await compute(
// PagedSearchResult.fromJson,
// PagedSearchResultJsonSerializer(
// jsonDecode(response.body), DocumentModel.fromJson),
// );
// if (data.count > 0) {
// consumptionFinished = true;
// final docId = data.results[0].id;
// getIt<DocumentStatusCubit>().updateStatus(
// DocumentProcessingStatus(
// currentProgress: 100,
// filename: documentFileName,
// maxProgress: 100,
// message: ProcessingMessage.finished,
// status: ProcessingStatus.success,
// taskId: DocumentProcessingStatus.unknownTaskId,
// documentId: docId,
// isApproximated: true,
// ),
// );
// return;
// }
// sleep(const Duration(seconds: 1));
// } while (!consumptionFinished && retryCount < maxRetries);
}
}
@@ -54,24 +54,27 @@ String translateError(BuildContext context, ErrorCode code) {
ErrorCode.suggestionsQueryError => S.of(context)!.couldNotLoadSuggestions,
ErrorCode.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,
),
],
);
}
}
-45
View File
@@ -1,45 +0,0 @@
import 'package:flutter/material.dart';
import 'package:flutter_svg/flutter_svg.dart';
class EmptyState extends StatelessWidget {
final String title;
final String subtitle;
final Widget? bottomChild;
const EmptyState({
Key? key,
required this.title,
required this.subtitle,
this.bottomChild,
}) : super(key: key);
@override
Widget build(BuildContext context) {
final size = MediaQuery.of(context).size;
return Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
SizedBox(
height: size.height / 3,
width: size.width / 3,
child: SvgPicture.asset("assets/images/empty-state.svg"),
),
Column(
children: [
Text(
title,
style: Theme.of(context).textTheme.titleLarge?.copyWith(
fontWeight: FontWeight.bold,
),
),
Text(
subtitle,
style: Theme.of(context).textTheme.titleMedium,
),
],
),
if (bottomChild != null) ...[bottomChild!] else ...[]
],
);
}
}
@@ -1,430 +0,0 @@
import 'package:flutter/material.dart';
import 'package:flutter_form_builder/flutter_form_builder.dart';
import 'package:flutter_typeahead/flutter_typeahead.dart';
typedef SelectionToTextTransformer<T> = String Function(T suggestion);
/// Text field that auto-completes user input from a list of items
class FormBuilderTypeAhead<T> extends FormBuilderField<T> {
/// Called with the search pattern to get the search suggestions.
///
/// This callback must not be null. It is be called by the TypeAhead widget
/// and provided with the search pattern. It should return a [List](https://api.dartlang.org/stable/2.0.0/dart-core/List-class.html)
/// of suggestions either synchronously, or asynchronously (as the result of a
/// [Future](https://api.dartlang.org/stable/dart-async/Future-class.html)).
/// Typically, the list of suggestions should not contain more than 4 or 5
/// entries. These entries will then be provided to [itemBuilder] to display
/// the suggestions.
///
/// Example:
/// ```dart
/// suggestionsCallback: (pattern) async {
/// return await _getSuggestions(pattern);
/// }
/// ```
final SuggestionsCallback<T> suggestionsCallback;
/// Called when a suggestion is tapped.
///
/// This callback must not be null. It is called by the TypeAhead widget and
/// provided with the value of the tapped suggestion.
///
/// For example, you might want to navigate to a specific view when the user
/// tabs a suggestion:
/// ```dart
/// onSuggestionSelected: (suggestion) {
/// Navigator.of(context).push(MaterialPageRoute(
/// builder: (context) => SearchResult(
/// searchItem: suggestion
/// )
/// ));
/// }
/// ```
///
/// Or to set the value of the text field:
/// ```dart
/// onSuggestionSelected: (suggestion) {
/// _controller.text = suggestion['name'];
/// }
/// ```
final SuggestionSelectionCallback<T>? onSuggestionSelected;
/// Called for each suggestion returned by [suggestionsCallback] to build the
/// corresponding widget.
///
/// This callback must not be null. It is called by the TypeAhead widget for
/// each suggestion, and expected to build a widget to display this
/// suggestion's info. For example:
///
/// ```dart
/// itemBuilder: (context, suggestion) {
/// return ListTile(
/// title: Text(suggestion['name']),
/// subtitle: Text('USD' + suggestion['price'].toString())
/// );
/// }
/// ```
final ItemBuilder<T> itemBuilder;
/// The decoration of the material sheet that contains the suggestions.
///
/// If null, default decoration with an elevation of 4.0 is used
final SuggestionsBoxDecoration suggestionsBoxDecoration;
/// Used to control the `_SuggestionsBox`. Allows manual control to
/// open, close, toggle, or resize the `_SuggestionsBox`.
final SuggestionsBoxController? suggestionsBoxController;
/// The duration to wait after the user stops typing before calling
/// [suggestionsCallback]
///
/// This is useful, because, if not set, a request for suggestions will be
/// sent for every character that the user types.
///
/// This duration is set by default to 300 milliseconds
final Duration debounceDuration;
/// Called when waiting for [suggestionsCallback] to return.
///
/// It is expected to return a widget to display while waiting.
/// For example:
/// ```dart
/// (BuildContext context) {
/// return Text('Loading...');
/// }
/// ```
///
/// If not specified, a [CircularProgressIndicator](https://docs.flutter.io/flutter/material/CircularProgressIndicator-class.html) is shown
final WidgetBuilder? loadingBuilder;
/// Called when [suggestionsCallback] returns an empty array.
///
/// It is expected to return a widget to display when no suggestions are
/// available.
/// For example:
/// ```dart
/// (BuildContext context) {
/// return Text('No Items Found!');
/// }
/// ```
///
/// If not specified, a simple text is shown
final WidgetBuilder? noItemsFoundBuilder;
/// Called when [suggestionsCallback] throws an exception.
///
/// It is called with the error object, and expected to return a widget to
/// display when an exception is thrown
/// For example:
/// ```dart
/// (BuildContext context, error) {
/// return Text('$error');
/// }
/// ```
///
/// If not specified, the error is shown in [ThemeData.errorColor](https://docs.flutter.io/flutter/material/ThemeData/errorColor.html)
final ErrorBuilder? errorBuilder;
/// Called to display animations when [suggestionsCallback] returns suggestions
///
/// It is provided with the suggestions box instance and the animation
/// controller, and expected to return some animation that uses the controller
/// to display the suggestion box.
///
/// For example:
/// ```dart
/// transitionBuilder: (context, suggestionsBox, animationController) {
/// return FadeTransition(
/// child: suggestionsBox,
/// opacity: CurvedAnimation(
/// parent: animationController,
/// curve: Curves.fastOutSlowIn
/// ),
/// );
/// }
/// ```
/// This argument is best used with [animationDuration] and [animationStart]
/// to fully control the animation.
///
/// To fully remove the animation, just return `suggestionsBox`
///
/// If not specified, a [SizeTransition](https://docs.flutter.io/flutter/widgets/SizeTransition-class.html) is shown.
final AnimationTransitionBuilder? transitionBuilder;
/// The duration that [transitionBuilder] animation takes.
///
/// This argument is best used with [transitionBuilder] and [animationStart]
/// to fully control the animation.
///
/// Defaults to 500 milliseconds.
final Duration animationDuration;
/// Determine the [SuggestionBox]'s direction.
///
/// If [AxisDirection.down], the [SuggestionBox] will be below the [TextField]
/// and the [_SuggestionsList] will grow **down**.
///
/// If [AxisDirection.up], the [SuggestionBox] will be above the [TextField]
/// and the [_SuggestionsList] will grow **up**.
///
/// [AxisDirection.left] and [AxisDirection.right] are not allowed.
final AxisDirection direction;
/// The value at which the [transitionBuilder] animation starts.
///
/// This argument is best used with [transitionBuilder] and [animationDuration]
/// to fully control the animation.
///
/// Defaults to 0.25.
final double animationStart;
/// The configuration of the [TextField](https://docs.flutter.io/flutter/material/TextField-class.html)
/// that the TypeAhead widget displays
final TextFieldConfiguration textFieldConfiguration;
/// How far below the text field should the suggestions box be
///
/// Defaults to 5.0
final double suggestionsBoxVerticalOffset;
/// If set to true, suggestions will be fetched immediately when the field is
/// added to the view.
///
/// But the suggestions box will only be shown when the field receives focus.
/// To make the field receive focus immediately, you can set the `autofocus`
/// property in the [textFieldConfiguration] to true
///
/// Defaults to false
final bool getImmediateSuggestions;
/// If set to true, no loading box will be shown while suggestions are
/// being fetched. [loadingBuilder] will also be ignored.
///
/// Defaults to false.
final bool hideOnLoading;
/// If set to true, nothing will be shown if there are no results.
/// [noItemsFoundBuilder] will also be ignored.
///
/// Defaults to false.
final bool hideOnEmpty;
/// If set to true, nothing will be shown if there is an error.
/// [errorBuilder] will also be ignored.
///
/// Defaults to false.
final bool hideOnError;
/// If set to false, the suggestions box will stay opened after
/// the keyboard is closed.
///
/// Defaults to true.
final bool hideSuggestionsOnKeyboardHide;
/// If set to false, the suggestions box will show a circular
/// progress indicator when retrieving suggestions.
///
/// Defaults to true.
final bool keepSuggestionsOnLoading;
/// If set to true, the suggestions box will remain opened even after
/// selecting a suggestion.
///
/// Note that if this is enabled, the only way
/// to close the suggestions box is either manually via the
/// `SuggestionsBoxController` or when the user closes the software
/// keyboard if `hideSuggestionsOnKeyboardHide` is set to true. Users
/// with a physical keyboard will be unable to close the
/// box without a manual way via `SuggestionsBoxController`.
///
/// Defaults to false.
final bool keepSuggestionsOnSuggestionSelected;
/// If set to true, in the case where the suggestions box has less than
/// _SuggestionsBoxController.minOverlaySpace to grow in the desired [direction], the direction axis
/// will be temporarily flipped if there's more room available in the opposite
/// direction.
///
/// Defaults to false
final bool autoFlipDirection;
final SelectionToTextTransformer<T>? selectionToTextTransformer;
/// Controls the text being edited.
///
/// If null, this widget will create its own [TextEditingController].
final TextEditingController? controller;
final bool hideKeyboard;
final ScrollController? scrollController;
/// Creates text field that auto-completes user input from a list of items
FormBuilderTypeAhead({
Key? key,
//From Super
AutovalidateMode autovalidateMode = AutovalidateMode.disabled,
bool enabled = true,
FocusNode? focusNode,
FormFieldSetter<T>? onSaved,
FormFieldValidator<T>? validator,
InputDecoration decoration = const InputDecoration(),
required String name,
required this.itemBuilder,
required this.suggestionsCallback,
T? initialValue,
ValueChanged<T?>? onChanged,
ValueTransformer<T?>? valueTransformer,
VoidCallback? onReset,
this.animationDuration = const Duration(milliseconds: 500),
this.animationStart = 0.25,
this.autoFlipDirection = false,
this.controller,
this.debounceDuration = const Duration(milliseconds: 300),
this.direction = AxisDirection.down,
this.errorBuilder,
this.getImmediateSuggestions = false,
this.hideKeyboard = false,
this.hideOnEmpty = false,
this.hideOnError = false,
this.hideOnLoading = false,
this.hideSuggestionsOnKeyboardHide = true,
this.keepSuggestionsOnLoading = true,
this.keepSuggestionsOnSuggestionSelected = false,
this.loadingBuilder,
this.noItemsFoundBuilder,
this.onSuggestionSelected,
this.scrollController,
this.selectionToTextTransformer,
this.suggestionsBoxController,
this.suggestionsBoxDecoration = const SuggestionsBoxDecoration(),
this.suggestionsBoxVerticalOffset = 5.0,
this.textFieldConfiguration = const TextFieldConfiguration(),
this.transitionBuilder,
}) : assert(T == String || selectionToTextTransformer != null),
super(
key: key,
initialValue: initialValue,
name: name,
validator: validator,
valueTransformer: valueTransformer,
onChanged: onChanged,
autovalidateMode: autovalidateMode,
onSaved: onSaved,
enabled: enabled,
onReset: onReset,
decoration: decoration,
focusNode: focusNode,
builder: (FormFieldState<T?> field) {
final state = field as FormBuilderTypeAheadState<T>;
final theme = Theme.of(state.context);
return TypeAheadField<T>(
textFieldConfiguration: textFieldConfiguration.copyWith(
enabled: state.enabled,
controller: state._typeAheadController,
style: state.enabled
? textFieldConfiguration.style
: theme.textTheme.titleMedium!.copyWith(
color: theme.disabledColor,
),
focusNode: state.effectiveFocusNode,
decoration: state.decoration,
),
// TODO HACK to satisfy strictness
suggestionsCallback: suggestionsCallback,
itemBuilder: itemBuilder,
transitionBuilder: (context, suggestionsBox, controller) =>
suggestionsBox,
onSuggestionSelected: (T suggestion) {
state.didChange(suggestion);
onSuggestionSelected?.call(suggestion);
},
getImmediateSuggestions: getImmediateSuggestions,
errorBuilder: errorBuilder,
noItemsFoundBuilder: noItemsFoundBuilder,
loadingBuilder: loadingBuilder,
debounceDuration: debounceDuration,
suggestionsBoxDecoration: suggestionsBoxDecoration,
suggestionsBoxVerticalOffset: suggestionsBoxVerticalOffset,
animationDuration: animationDuration,
animationStart: animationStart,
direction: direction,
hideOnLoading: hideOnLoading,
hideOnEmpty: hideOnEmpty,
hideOnError: hideOnError,
hideSuggestionsOnKeyboardHide: hideSuggestionsOnKeyboardHide,
keepSuggestionsOnLoading: keepSuggestionsOnLoading,
autoFlipDirection: autoFlipDirection,
suggestionsBoxController: suggestionsBoxController,
keepSuggestionsOnSuggestionSelected:
keepSuggestionsOnSuggestionSelected,
hideKeyboard: hideKeyboard,
scrollController: scrollController,
);
},
);
@override
FormBuilderTypeAheadState<T> createState() => FormBuilderTypeAheadState<T>();
}
class FormBuilderTypeAheadState<T>
extends FormBuilderFieldState<FormBuilderTypeAhead<T>, T> {
late TextEditingController _typeAheadController;
@override
void initState() {
super.initState();
_typeAheadController = widget.controller ??
TextEditingController(text: _getTextString(initialValue));
// _typeAheadController.addListener(_handleControllerChanged);
}
// void _handleControllerChanged() {
// Suppress changes that originated from within this class.
//
// In the case where a controller has been passed in to this widget, we
// register this change listener. In these cases, we'll also receive change
// notifications for changes originating from within this class -- for
// example, the reset() method. In such cases, the FormField value will
// already have been set.
// if (_typeAheadController.text != value) {
// didChange(_typeAheadController.text as T);
// }
// }
@override
void didChange(T? value) {
super.didChange(value);
var text = _getTextString(value);
if (_typeAheadController.text != text) {
_typeAheadController.text = text;
}
}
@override
void dispose() {
// Dispose the _typeAheadController when initState created it
super.dispose();
_typeAheadController.dispose();
}
@override
void reset() {
super.reset();
_typeAheadController.text = _getTextString(initialValue);
}
String _getTextString(T? value) {
var text = value == null
? ''
: widget.selectionToTextTransformer != null
? widget.selectionToTextTransformer!(value)
: value.toString();
return text;
}
}
+35
View File
@@ -0,0 +1,35 @@
import 'dart:async';
import 'package:flutter/material.dart';
class FutureOrBuilder<T> extends StatelessWidget {
final FutureOr<T>? futureOrValue;
final T? initialData;
final AsyncWidgetBuilder<T> builder;
const FutureOrBuilder({
super.key,
FutureOr<T>? future,
this.initialData,
required this.builder,
}) : futureOrValue = future;
@override
Widget build(BuildContext context) {
final futureOrValue = this.futureOrValue;
if (futureOrValue is T) {
return builder(
context,
AsyncSnapshot.withData(ConnectionState.done, futureOrValue),
);
} else {
return FutureBuilder(
future: futureOrValue,
initialData: initialData,
builder: builder,
);
}
}
}
-288
View File
@@ -1,288 +0,0 @@
// MIT License
//
// Copyright (c) 2019 Simon Lightfoot
//
// Permission is hereby granted, free of charge, to any person obtaining a copy
// of this software and associated documentation files (the "Software"), to deal
// in the Software without restriction, including without limitation the rights
// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
// copies of the Software, and to permit persons to whom the Software is
// furnished to do so, subject to the following conditions:
//
// The above copyright notice and this permission notice shall be included in all
// copies or substantial portions of the Software.
//
// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
// SOFTWARE.
//
import 'dart:async';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
typedef ChipsInputSuggestions<T> = Future<List<T>> Function(String query);
typedef ChipSelected<T> = void Function(T data, bool selected);
typedef ChipsBuilder<T> = Widget Function(
BuildContext context, ChipsInputState<T> state, T data);
class ChipsInput<T> extends StatefulWidget {
const ChipsInput({
super.key,
this.decoration = const InputDecoration(),
required this.chipBuilder,
required this.suggestionBuilder,
required this.findSuggestions,
required this.onChanged,
this.onChipTapped,
});
final InputDecoration decoration;
final ChipsInputSuggestions<T> findSuggestions;
final ValueChanged<List<T>> onChanged;
final ValueChanged<T>? onChipTapped;
final ChipsBuilder<T> chipBuilder;
final ChipsBuilder<T> suggestionBuilder;
@override
ChipsInputState<T> createState() => ChipsInputState<T>();
}
class ChipsInputState<T> extends State<ChipsInput<T>> {
static const kObjectReplacementChar = 0xFFFC;
Set<T> _chips = {};
List<T> _suggestions = [];
int _searchId = 0;
FocusNode _focusNode = FocusNode();
TextEditingValue _value = const TextEditingValue();
TextInputConnection? _connection;
String get text {
return String.fromCharCodes(
_value.text.codeUnits.where((ch) => ch != kObjectReplacementChar),
);
}
TextEditingValue get currentTextEditingValue => _value;
bool get _hasInputConnection =>
_connection != null && (_connection?.attached ?? false);
void requestKeyboard() {
if (_focusNode.hasFocus) {
_openInputConnection();
} else {
FocusScope.of(context).requestFocus(_focusNode);
}
}
void selectSuggestion(T data) {
setState(() {
_chips.add(data);
_updateTextInputState();
_suggestions = [];
});
widget.onChanged(_chips.toList(growable: false));
}
void deleteChip(T data) {
setState(() {
_chips.remove(data);
_updateTextInputState();
});
widget.onChanged(_chips.toList(growable: false));
}
@override
void initState() {
super.initState();
_focusNode = FocusNode();
_focusNode.addListener(_onFocusChanged);
}
void _onFocusChanged() {
if (_focusNode.hasFocus) {
_openInputConnection();
} else {
_closeInputConnectionIfNeeded();
}
setState(() {
// rebuild so that _TextCursor is hidden.
});
}
@override
void dispose() {
_focusNode.dispose();
_closeInputConnectionIfNeeded();
super.dispose();
}
void _openInputConnection() {
if (!_hasInputConnection) {
_connection?.setEditingState(_value);
}
_connection?.show();
}
void _closeInputConnectionIfNeeded() {
if (_hasInputConnection) {
_connection?.close();
_connection = null;
}
}
@override
Widget build(BuildContext context) {
var chipsChildren = _chips
.map<Widget>(
(data) => widget.chipBuilder(context, this, data),
)
.toList();
final theme = Theme.of(context);
chipsChildren.add(
SizedBox(
height: 32.0,
child: Row(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: <Widget>[
Text(
text,
style: theme.textTheme.bodyLarge?.copyWith(
height: 1.5,
),
),
_TextCaret(
resumed: _focusNode.hasFocus,
),
],
),
),
);
return Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
//mainAxisSize: MainAxisSize.min,
children: <Widget>[
GestureDetector(
behavior: HitTestBehavior.opaque,
onTap: requestKeyboard,
child: InputDecorator(
decoration: widget.decoration,
isFocused: _focusNode.hasFocus,
isEmpty: _value.text.isEmpty,
child: Wrap(
children: chipsChildren,
spacing: 4.0,
runSpacing: 4.0,
),
),
),
Expanded(
child: ListView.builder(
itemCount: _suggestions.length,
itemBuilder: (BuildContext context, int index) {
return widget.suggestionBuilder(
context, this, _suggestions[index]);
},
),
),
],
);
}
void updateEditingValue(TextEditingValue value) {
final oldCount = _countReplacements(_value);
final newCount = _countReplacements(value);
setState(() {
if (newCount < oldCount) {
_chips = Set.from(_chips.take(newCount));
}
_value = value;
});
_onSearchChanged(text);
}
int _countReplacements(TextEditingValue value) {
return value.text.codeUnits
.where((ch) => ch == kObjectReplacementChar)
.length;
}
void _updateTextInputState() {
final text =
String.fromCharCodes(_chips.map((_) => kObjectReplacementChar));
_value = TextEditingValue(
text: text,
selection: TextSelection.collapsed(offset: text.length),
composing: TextRange(start: 0, end: text.length),
);
_connection?.setEditingState(_value);
}
void _onSearchChanged(String value) async {
final localId = ++_searchId;
final results = await widget.findSuggestions(value);
if (_searchId == localId && mounted) {
setState(() => _suggestions = results
.where((profile) => !_chips.contains(profile))
.toList(growable: false));
}
}
}
class _TextCaret extends StatefulWidget {
const _TextCaret({
this.resumed = false,
});
final bool resumed;
@override
_TextCursorState createState() => _TextCursorState();
}
class _TextCursorState extends State<_TextCaret>
with SingleTickerProviderStateMixin {
bool _displayed = false;
late Timer _timer;
@override
void initState() {
super.initState();
}
void _onTimer(Timer timer) {
setState(() => _displayed = !_displayed);
}
@override
void dispose() {
_timer.cancel();
super.dispose();
}
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
return FractionallySizedBox(
heightFactor: 0.7,
child: Opacity(
opacity: _displayed && widget.resumed ? 1.0 : 0.0,
child: Container(
width: 2.0,
color: theme.primaryColor,
),
),
);
}
}
+108 -34
View File
@@ -1,16 +1,19 @@
import 'package:flutter/cupertino.dart';
import 'package:flutter/gestures.dart';
import 'package:flutter/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();
}
}
}
@@ -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));
}
+104 -101
View File
@@ -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),
@@ -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),
);
},
),
],
),
+17 -8
View File
@@ -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) {
-330
View File
@@ -1,330 +0,0 @@
import 'dart:async';
import 'dart:developer';
import 'dart:io';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:fluttertoast/fluttertoast.dart';
import 'package:hive/hive.dart';
import 'package:paperless_api/paperless_api.dart';
import 'package:paperless_mobile/core/bloc/connectivity_cubit.dart';
import 'package:paperless_mobile/core/config/hive/hive_config.dart';
import 'package:paperless_mobile/core/database/tables/global_settings.dart';
import 'package:paperless_mobile/core/database/tables/local_user_account.dart';
import 'package:paperless_mobile/core/global/constants.dart';
import 'package:paperless_mobile/core/navigation/push_routes.dart';
import 'package:paperless_mobile/core/repository/label_repository.dart';
import 'package:paperless_mobile/core/repository/saved_view_repository.dart';
import 'package:paperless_mobile/core/service/file_description.dart';
import 'package:paperless_mobile/core/translation/error_code_localization_mapper.dart';
import 'package:paperless_mobile/features/document_scan/view/scanner_page.dart';
import 'package:paperless_mobile/features/documents/view/pages/documents_page.dart';
import 'package:paperless_mobile/features/home/view/route_description.dart';
import 'package:paperless_mobile/features/inbox/cubit/inbox_cubit.dart';
import 'package:paperless_mobile/features/inbox/view/pages/inbox_page.dart';
import 'package:paperless_mobile/features/labels/view/pages/labels_page.dart';
import 'package:paperless_mobile/features/notifications/services/local_notification_service.dart';
import 'package:paperless_mobile/features/sharing/share_intent_queue.dart';
import 'package:paperless_mobile/features/tasks/cubit/task_status_cubit.dart';
import 'package:paperless_mobile/generated/l10n/app_localizations.dart';
import 'package:receive_sharing_intent/receive_sharing_intent.dart';
import 'package:responsive_builder/responsive_builder.dart';
/// Wrapper around all functionality for a logged in user.
/// Performs initialization logic.
class HomePage extends StatefulWidget {
final int paperlessApiVersion;
const HomePage({Key? key, required this.paperlessApiVersion})
: super(key: key);
@override
_HomePageState createState() => _HomePageState();
}
class _HomePageState extends State<HomePage> with WidgetsBindingObserver {
int _currentIndex = 0;
Timer? _inboxTimer;
late final StreamSubscription _shareMediaSubscription;
@override
void initState() {
super.initState();
WidgetsBinding.instance.addObserver(this);
final currentUser = Hive.box<GlobalSettings>(HiveBoxes.globalSettings)
.getValue()!
.currentLoggedInUser!;
// For sharing files coming from outside the app while the app is still opened
_shareMediaSubscription = ReceiveSharingIntent.getMediaStream().listen(
(files) =>
ShareIntentQueue.instance.addAll(files, userId: currentUser));
// For sharing files coming from outside the app while the app is closed
ReceiveSharingIntent.getInitialMedia().then((files) =>
ShareIntentQueue.instance.addAll(files, userId: currentUser));
WidgetsBinding.instance.addPostFrameCallback((timeStamp) {
_listenForReceivedFiles();
});
}
@override
void didChangeDependencies() {
super.didChangeDependencies();
}
void _listenToInboxChanges() {
if (LocalUserAccount.current.paperlessUser.canViewTags) {
_inboxTimer = Timer.periodic(const Duration(seconds: 60), (timer) {
if (!mounted) {
timer.cancel();
} else {
context.read<InboxCubit>().refreshItemsInInboxCount();
}
});
}
}
@override
void didChangeAppLifecycleState(AppLifecycleState state) {
switch (state) {
case AppLifecycleState.resumed:
log('App is now in foreground');
context.read<ConnectivityCubit>().reload();
log("Reloaded device connectivity state");
if (!(_inboxTimer?.isActive ?? true)) {
_listenToInboxChanges();
}
break;
case AppLifecycleState.inactive:
case AppLifecycleState.paused:
case AppLifecycleState.detached:
default:
log('App is now in background');
_inboxTimer?.cancel();
break;
}
}
@override
void dispose() {
WidgetsBinding.instance.removeObserver(this);
_inboxTimer?.cancel();
_shareMediaSubscription.cancel();
super.dispose();
}
void _listenForReceivedFiles() async {
final currentUser = Hive.box<GlobalSettings>(HiveBoxes.globalSettings)
.getValue()!
.currentLoggedInUser!;
if (ShareIntentQueue.instance.userHasUnhandlesFiles(currentUser)) {
await _handleReceivedFile(ShareIntentQueue.instance.pop(currentUser)!);
}
ShareIntentQueue.instance.addListener(() async {
final queue = ShareIntentQueue.instance;
while (queue.userHasUnhandlesFiles(currentUser)) {
final file = queue.pop(currentUser)!;
await _handleReceivedFile(file);
}
});
}
bool _isFileTypeSupported(SharedMediaFile file) {
return supportedFileExtensions.contains(
file.path.split('.').last.toLowerCase(),
);
}
Future<void> _handleReceivedFile(final SharedMediaFile file) async {
SharedMediaFile mediaFile;
if (Platform.isIOS) {
// Workaround for file not found on iOS: https://stackoverflow.com/a/72813212
mediaFile = SharedMediaFile(
file.path.replaceAll('file://', ''),
file.thumbnail,
file.duration,
file.type,
);
} else {
mediaFile = file;
}
debugPrint("Consuming media file: ${mediaFile.path}");
if (!_isFileTypeSupported(mediaFile)) {
Fluttertoast.showToast(
msg: translateError(context, ErrorCode.unsupportedFileFormat),
);
if (Platform.isAndroid) {
// As stated in the docs, SystemNavigator.pop() is ignored on IOS to comply with HCI guidelines.
await SystemNavigator.pop();
}
return;
}
if (!LocalUserAccount.current.paperlessUser.canCreateDocuments) {
Fluttertoast.showToast(
msg: "You do not have the permissions to upload documents.",
);
return;
}
final fileDescription = FileDescription.fromPath(mediaFile.path);
if (await File(mediaFile.path).exists()) {
final bytes = await File(mediaFile.path).readAsBytes();
final result = await pushDocumentUploadPreparationPage(
context,
bytes: bytes,
filename: fileDescription.filename,
title: fileDescription.filename,
fileExtension: fileDescription.extension,
);
if (result?.success ?? false) {
await Fluttertoast.showToast(
msg: S.of(context)!.documentSuccessfullyUploadedProcessing,
);
SystemNavigator.pop();
}
} else {
Fluttertoast.showToast(
msg: S.of(context)!.couldNotAccessReceivedFile,
toastLength: Toast.LENGTH_LONG,
);
}
}
@override
Widget build(BuildContext context) {
final destinations = [
RouteDescription(
icon: const Icon(Icons.description_outlined),
selectedIcon: Icon(
Icons.description,
color: Theme.of(context).colorScheme.primary,
),
label: S.of(context)!.documents,
),
if (LocalUserAccount.current.paperlessUser.canCreateDocuments)
RouteDescription(
icon: const Icon(Icons.document_scanner_outlined),
selectedIcon: Icon(
Icons.document_scanner,
color: Theme.of(context).colorScheme.primary,
),
label: S.of(context)!.scanner,
),
RouteDescription(
icon: const Icon(Icons.sell_outlined),
selectedIcon: Icon(
Icons.sell,
color: Theme.of(context).colorScheme.primary,
),
label: S.of(context)!.labels,
),
if (LocalUserAccount.current.paperlessUser.canViewTags)
RouteDescription(
icon: const Icon(Icons.inbox_outlined),
selectedIcon: Icon(
Icons.inbox,
color: Theme.of(context).colorScheme.primary,
),
label: S.of(context)!.inbox,
badgeBuilder: (icon) => BlocBuilder<InboxCubit, InboxState>(
builder: (context, state) {
return Badge.count(
isLabelVisible: state.itemsInInboxCount > 0,
count: state.itemsInInboxCount,
child: icon,
);
},
),
),
];
final routes = <Widget>[
const DocumentsPage(),
if (LocalUserAccount.current.paperlessUser.canCreateDocuments)
const ScannerPage(),
const LabelsPage(),
if (LocalUserAccount.current.paperlessUser.canViewTags) const InboxPage(),
];
return MultiBlocListener(
listeners: [
BlocListener<ConnectivityCubit, ConnectivityState>(
// If app was started offline, load data once it comes back online.
listenWhen: (previous, current) =>
previous != ConnectivityState.connected &&
current == ConnectivityState.connected,
listener: (context, state) async {
try {
debugPrint(
"[HomePage] BlocListener#listener: "
"Loading saved views and labels...",
);
await Future.wait([
context.read<LabelRepository>().initialize(),
context.read<SavedViewRepository>().initialize(),
]);
debugPrint("[HomePage] BlocListener#listener: "
"Saved views and labels successfully loaded.");
} catch (error, stackTrace) {
debugPrint(
'[HomePage] BlocListener.listener: '
'An error occurred while loading saved views and labels.\n'
'${error.toString()}',
);
debugPrintStack(stackTrace: stackTrace);
}
},
),
BlocListener<TaskStatusCubit, TaskStatusState>(
listener: (context, state) {
if (state.task != null) {
// Handle local notifications on task change (only when app is running for now).
context
.read<LocalNotificationService>()
.notifyTaskChanged(state.task!);
}
},
),
],
child: ResponsiveBuilder(
builder: (context, sizingInformation) {
if (!sizingInformation.isMobile) {
return Scaffold(
body: Row(
children: [
NavigationRail(
labelType: NavigationRailLabelType.all,
destinations: destinations
.map((e) => e.toNavigationRailDestination())
.toList(),
selectedIndex: _currentIndex,
onDestinationSelected: _onNavigationChanged,
),
const VerticalDivider(thickness: 1, width: 1),
Expanded(
child: routes[_currentIndex],
),
],
),
);
}
return Scaffold(
bottomNavigationBar: NavigationBar(
labelBehavior: NavigationDestinationLabelBehavior.alwaysShow,
elevation: 4.0,
selectedIndex: _currentIndex,
onDestinationSelected: _onNavigationChanged,
destinations:
destinations.map((e) => e.toNavigationDestination()).toList(),
),
body: routes[_currentIndex],
);
},
),
);
}
void _onNavigationChanged(index) {
if (_currentIndex != index) {
setState(() => _currentIndex = index);
}
}
}
-204
View File
@@ -1,204 +0,0 @@
import 'package:flutter/material.dart';
import 'package:flutter_cache_manager/flutter_cache_manager.dart';
import 'package:hive_flutter/adapters.dart';
import 'package:paperless_api/paperless_api.dart';
import 'package:paperless_mobile/core/config/hive/hive_config.dart';
import 'package:paperless_mobile/core/database/tables/local_user_account.dart';
import 'package:paperless_mobile/core/database/tables/local_user_app_state.dart';
import 'package:paperless_mobile/core/factory/paperless_api_factory.dart';
import 'package:paperless_mobile/core/notifier/document_changed_notifier.dart';
import 'package:paperless_mobile/core/repository/label_repository.dart';
import 'package:paperless_mobile/core/repository/saved_view_repository.dart';
import 'package:paperless_mobile/core/repository/user_repository.dart';
import 'package:paperless_mobile/core/security/session_manager.dart';
import 'package:paperless_mobile/core/service/dio_file_service.dart';
import 'package:paperless_mobile/features/document_scan/cubit/document_scanner_cubit.dart';
import 'package:paperless_mobile/features/documents/cubit/documents_cubit.dart';
import 'package:paperless_mobile/features/home/view/home_page.dart';
import 'package:paperless_mobile/features/home/view/model/api_version.dart';
import 'package:paperless_mobile/features/inbox/cubit/inbox_cubit.dart';
import 'package:paperless_mobile/features/labels/cubit/label_cubit.dart';
import 'package:paperless_mobile/features/saved_view/cubit/saved_view_cubit.dart';
import 'package:paperless_mobile/features/settings/view/widgets/global_settings_builder.dart';
import 'package:paperless_mobile/features/tasks/cubit/task_status_cubit.dart';
import 'package:provider/provider.dart';
class HomeRoute extends StatelessWidget {
/// The id of the currently authenticated user (e.g. demo@paperless.example.com)
final String localUserId;
/// The Paperless API version of the currently connected instance
final int paperlessApiVersion;
// A factory providing the API implementations given an API version
final PaperlessApiFactory paperlessProviderFactory;
const HomeRoute({
super.key,
required this.paperlessApiVersion,
required this.paperlessProviderFactory,
required this.localUserId,
});
@override
Widget build(BuildContext context) {
return GlobalSettingsBuilder(
builder: (context, settings) {
final currentLocalUserId = settings.currentLoggedInUser;
if (currentLocalUserId == null) {
// This is the case when the current user logs out of the app.
return SizedBox.shrink();
}
final currentUser =
Hive.box<LocalUserAccount>(HiveBoxes.localUserAccount)
.get(currentLocalUserId)!;
final apiVersion = ApiVersion(paperlessApiVersion);
return MultiProvider(
providers: [
Provider.value(value: apiVersion),
Provider<CacheManager>(
create: (context) => CacheManager(
Config(
// Isolated cache per user.
localUserId,
fileService:
DioFileService(context.read<SessionManager>().client),
),
),
),
ProxyProvider<SessionManager, PaperlessDocumentsApi>(
update: (context, value, previous) =>
paperlessProviderFactory.createDocumentsApi(
value.client,
apiVersion: paperlessApiVersion,
),
),
ProxyProvider<SessionManager, PaperlessLabelsApi>(
update: (context, value, previous) =>
paperlessProviderFactory.createLabelsApi(
value.client,
apiVersion: paperlessApiVersion,
),
),
ProxyProvider<SessionManager, PaperlessSavedViewsApi>(
update: (context, value, previous) =>
paperlessProviderFactory.createSavedViewsApi(
value.client,
apiVersion: paperlessApiVersion,
),
),
ProxyProvider<SessionManager, PaperlessServerStatsApi>(
update: (context, value, previous) =>
paperlessProviderFactory.createServerStatsApi(
value.client,
apiVersion: paperlessApiVersion,
),
),
ProxyProvider<SessionManager, PaperlessTasksApi>(
update: (context, value, previous) =>
paperlessProviderFactory.createTasksApi(
value.client,
apiVersion: paperlessApiVersion,
),
),
if (apiVersion.hasMultiUserSupport)
ProxyProvider<SessionManager, PaperlessUserApiV3>(
update: (context, value, previous) => PaperlessUserApiV3Impl(
value.client,
),
),
],
builder: (context, child) {
return MultiProvider(
providers: [
ProxyProvider<PaperlessLabelsApi, LabelRepository>(
update: (context, value, previous) {
final repo = LabelRepository(value);
if (currentUser.paperlessUser.canViewCorrespondents) {
repo.findAllCorrespondents();
}
if (currentUser.paperlessUser.canViewDocumentTypes) {
repo.findAllDocumentTypes();
}
if (currentUser.paperlessUser.canViewTags) {
repo.findAllTags();
}
if (currentUser.paperlessUser.canViewStoragePaths) {
repo.findAllStoragePaths();
}
return repo;
},
),
ProxyProvider<PaperlessSavedViewsApi, SavedViewRepository>(
update: (context, value, previous) {
final repo = SavedViewRepository(value);
if (currentUser.paperlessUser.canViewSavedViews) {
repo.initialize();
}
return repo;
},
),
],
builder: (context, child) {
return MultiProvider(
providers: [
ProxyProvider3<
PaperlessDocumentsApi,
DocumentChangedNotifier,
LabelRepository,
DocumentsCubit>(
update:
(context, docApi, notifier, labelRepo, previous) =>
DocumentsCubit(
docApi,
notifier,
labelRepo,
Hive.box<LocalUserAppState>(HiveBoxes.localUserAppState)
.get(currentLocalUserId)!,
)..initialize(),
),
Provider(
create: (context) =>
DocumentScannerCubit(context.read())),
ProxyProvider4<
PaperlessDocumentsApi,
PaperlessServerStatsApi,
LabelRepository,
DocumentChangedNotifier,
InboxCubit>(
update: (context, docApi, statsApi, labelRepo, notifier,
previous) =>
InboxCubit(
docApi,
statsApi,
labelRepo,
notifier,
)..initialize(),
),
ProxyProvider<SavedViewRepository, SavedViewCubit>(
update: (context, savedViewRepo, previous) =>
SavedViewCubit(savedViewRepo),
),
ProxyProvider<LabelRepository, LabelCubit>(
update: (context, value, previous) => LabelCubit(value),
),
ProxyProvider<PaperlessTasksApi, TaskStatusCubit>(
update: (context, value, previous) =>
TaskStatusCubit(value),
),
if (paperlessApiVersion >= 3)
ProxyProvider<PaperlessUserApiV3, UserRepository>(
update: (context, value, previous) =>
UserRepository(value)..initialize(),
),
],
child: HomePage(paperlessApiVersion: paperlessApiVersion),
);
},
);
},
);
},
);
}
}
@@ -0,0 +1,217 @@
import 'package:flutter/material.dart';
import 'package:flutter_cache_manager/flutter_cache_manager.dart';
import 'package:hive_flutter/adapters.dart';
import 'package:paperless_api/paperless_api.dart';
import 'package:paperless_mobile/core/config/hive/hive_config.dart';
import 'package:paperless_mobile/core/config/hive/hive_extensions.dart';
import 'package:paperless_mobile/core/database/tables/local_user_app_state.dart';
import 'package:paperless_mobile/core/factory/paperless_api_factory.dart';
import 'package:paperless_mobile/core/repository/label_repository.dart';
import 'package:paperless_mobile/core/repository/saved_view_repository.dart';
import 'package:paperless_mobile/core/repository/user_repository.dart';
import 'package:paperless_mobile/core/security/session_manager.dart';
import 'package:paperless_mobile/core/service/dio_file_service.dart';
import 'package:paperless_mobile/features/document_scan/cubit/document_scanner_cubit.dart';
import 'package:paperless_mobile/features/documents/cubit/documents_cubit.dart';
import 'package:paperless_mobile/features/home/view/model/api_version.dart';
import 'package:paperless_mobile/features/inbox/cubit/inbox_cubit.dart';
import 'package:paperless_mobile/features/labels/cubit/label_cubit.dart';
import 'package:paperless_mobile/features/saved_view/cubit/saved_view_cubit.dart';
import 'package:paperless_mobile/features/settings/view/widgets/global_settings_builder.dart';
import 'package:paperless_mobile/features/tasks/model/pending_tasks_notifier.dart';
import 'package:provider/provider.dart';
class HomeShellWidget extends StatelessWidget {
/// The id of the currently authenticated user (e.g. demo@paperless.example.com)
final String localUserId;
/// The Paperless API version of the currently connected instance
final int paperlessApiVersion;
// A factory providing the API implementations given an API version
final PaperlessApiFactory paperlessProviderFactory;
final Widget child;
const HomeShellWidget({
super.key,
required this.paperlessApiVersion,
required this.paperlessProviderFactory,
required this.localUserId,
required this.child,
});
@override
Widget build(BuildContext context) {
return GlobalSettingsBuilder(
builder: (context, settings) {
final currentUserId = settings.loggedInUserId;
final apiVersion = ApiVersion(paperlessApiVersion);
return ValueListenableBuilder(
valueListenable:
Hive.localUserAccountBox.listenable(keys: [currentUserId]),
builder: (context, box, _) {
if (currentUserId == null) {
//This only happens during logout...
//TODO: Find way so this does not occur anymore
return SizedBox.shrink();
}
final currentLocalUser = box.get(currentUserId)!;
return MultiProvider(
key: ValueKey(currentUserId),
providers: [
Provider.value(value: currentLocalUser),
Provider.value(value: apiVersion),
Provider(
create: (context) => CacheManager(
Config(
// Isolated cache per user.
localUserId,
fileService:
DioFileService(context.read<SessionManager>().client),
),
),
),
Provider(
create: (context) =>
paperlessProviderFactory.createDocumentsApi(
context.read<SessionManager>().client,
apiVersion: paperlessApiVersion,
),
),
Provider(
create: (context) => paperlessProviderFactory.createLabelsApi(
context.read<SessionManager>().client,
apiVersion: paperlessApiVersion,
),
),
Provider(
create: (context) =>
paperlessProviderFactory.createSavedViewsApi(
context.read<SessionManager>().client,
apiVersion: paperlessApiVersion,
),
),
Provider(
create: (context) =>
paperlessProviderFactory.createServerStatsApi(
context.read<SessionManager>().client,
apiVersion: paperlessApiVersion,
),
),
Provider(
create: (context) => paperlessProviderFactory.createTasksApi(
context.read<SessionManager>().client,
apiVersion: paperlessApiVersion,
),
),
if (currentLocalUser.hasMultiUserSupport)
Provider(
create: (context) => PaperlessUserApiV3Impl(
context.read<SessionManager>().client,
),
),
],
builder: (context, _) {
return MultiProvider(
providers: [
Provider(
create: (context) {
final repo = LabelRepository(context.read());
if (currentLocalUser
.paperlessUser.canViewCorrespondents) {
repo.findAllCorrespondents();
}
if (currentLocalUser
.paperlessUser.canViewDocumentTypes) {
repo.findAllDocumentTypes();
}
if (currentLocalUser.paperlessUser.canViewTags) {
repo.findAllTags();
}
if (currentLocalUser
.paperlessUser.canViewStoragePaths) {
repo.findAllStoragePaths();
}
return repo;
},
),
Provider(
create: (context) {
final repo = SavedViewRepository(context.read());
if (currentLocalUser.paperlessUser.canViewSavedViews) {
repo.initialize();
}
return repo;
},
),
],
builder: (context, _) {
return MultiProvider(
providers: [
Provider(
lazy: false,
create: (context) => DocumentsCubit(
context.read(),
context.read(),
context.read(),
Hive.box<LocalUserAppState>(
HiveBoxes.localUserAppState)
.get(currentUserId)!,
context.read(),
)..initialize(),
),
Provider(
create: (context) =>
DocumentScannerCubit(context.read())
..initialize(),
),
Provider(
create: (context) {
final inboxCubit = InboxCubit(
context.read(),
context.read(),
context.read(),
context.read(),
context.read(),
);
if (currentLocalUser.paperlessUser.canViewInbox) {
inboxCubit.initialize();
}
return inboxCubit;
},
),
Provider(
create: (context) => SavedViewCubit(
context.read(),
),
),
Provider(
create: (context) => LabelCubit(
context.read(),
),
),
ChangeNotifierProvider(
create: (context) => PendingTasksNotifier(
context.read(),
),
),
if (currentLocalUser.hasMultiUserSupport)
Provider(
create: (context) => UserRepository(
context.read(),
)..initialize(),
),
],
child: child,
);
},
);
},
);
},
);
},
);
}
}
@@ -1,7 +1,7 @@
class ApiVersion {
final int version;
ApiVersion(this.version);
const ApiVersion(this.version);
bool get hasMultiUserSupport => version >= 3;
}
@@ -1,41 +0,0 @@
import 'package:flutter/material.dart';
class RouteDescription {
final String label;
final Icon icon;
final Icon selectedIcon;
final Widget Function(Widget icon)? badgeBuilder;
final bool enabled;
RouteDescription({
required this.label,
required this.icon,
required this.selectedIcon,
this.badgeBuilder,
this.enabled = true,
});
NavigationDestination toNavigationDestination() {
return NavigationDestination(
label: label,
icon: badgeBuilder?.call(icon) ?? icon,
selectedIcon: badgeBuilder?.call(selectedIcon) ?? selectedIcon,
);
}
NavigationRailDestination toNavigationRailDestination() {
return NavigationRailDestination(
label: Text(label),
icon: icon,
selectedIcon: selectedIcon,
);
}
BottomNavigationBarItem toBottomNavigationBarItem() {
return BottomNavigationBarItem(
label: label,
icon: badgeBuilder?.call(icon) ?? icon,
activeIcon: badgeBuilder?.call(selectedIcon) ?? selectedIcon,
);
}
}
@@ -0,0 +1,151 @@
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:go_router/go_router.dart';
import 'package:paperless_api/paperless_api.dart';
import 'package:paperless_mobile/features/app_drawer/view/app_drawer.dart';
import 'package:paperless_mobile/features/inbox/cubit/inbox_cubit.dart';
import 'package:paperless_mobile/generated/l10n/app_localizations.dart';
import 'package:paperless_mobile/theme.dart';
class ScaffoldWithNavigationBar extends StatefulWidget {
final UserModel authenticatedUser;
final StatefulNavigationShell navigationShell;
const ScaffoldWithNavigationBar({
super.key,
required this.authenticatedUser,
required this.navigationShell,
});
@override
State<ScaffoldWithNavigationBar> createState() =>
ScaffoldWithNavigationBarState();
}
class ScaffoldWithNavigationBarState extends State<ScaffoldWithNavigationBar> {
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
return AnnotatedRegion<SystemUiOverlayStyle>(
value: buildOverlayStyle(theme),
child: Scaffold(
drawer: const AppDrawer(),
bottomNavigationBar: NavigationBar(
elevation: 3,
backgroundColor: Theme.of(context).colorScheme.surface,
selectedIndex: widget.navigationShell.currentIndex,
onDestinationSelected: (index) {
widget.navigationShell.goBranch(
index,
initialLocation: index == widget.navigationShell.currentIndex,
);
},
destinations: [
NavigationDestination(
icon: const Icon(Icons.home_outlined),
selectedIcon: Icon(
Icons.home,
color: theme.colorScheme.primary,
),
label: S.of(context)!.home,
),
_toggleDestination(
NavigationDestination(
icon: const Icon(Icons.description_outlined),
selectedIcon: Icon(
Icons.description,
color: theme.colorScheme.primary,
),
label: S.of(context)!.documents,
),
disableWhen: !widget.authenticatedUser.canViewDocuments,
),
_toggleDestination(
NavigationDestination(
icon: const Icon(Icons.document_scanner_outlined),
selectedIcon: Icon(
Icons.document_scanner,
color: theme.colorScheme.primary,
),
label: S.of(context)!.scanner,
),
disableWhen: !widget.authenticatedUser.canCreateDocuments,
),
_toggleDestination(
NavigationDestination(
icon: const Icon(Icons.sell_outlined),
selectedIcon: Icon(
Icons.sell,
color: theme.colorScheme.primary,
),
label: S.of(context)!.labels,
),
disableWhen: !widget.authenticatedUser.canViewAnyLabel,
),
_toggleDestination(
NavigationDestination(
icon: Builder(
builder: (context) {
return BlocBuilder<InboxCubit, InboxState>(
builder: (context, state) {
return Badge.count(
isLabelVisible: state.itemsInInboxCount > 0,
count: state.itemsInInboxCount,
child: const Icon(Icons.inbox_outlined),
);
},
);
},
),
selectedIcon: BlocBuilder<InboxCubit, InboxState>(
builder: (context, state) {
return Badge.count(
isLabelVisible: state.itemsInInboxCount > 0 &&
widget.authenticatedUser.canViewInbox,
count: state.itemsInInboxCount,
child: Icon(
Icons.inbox,
color: theme.colorScheme.primary,
),
);
},
),
label: S.of(context)!.inbox,
),
disableWhen: !widget.authenticatedUser.canViewInbox,
),
],
),
body: widget.navigationShell,
),
);
}
Widget _toggleDestination(
Widget destination, {
required bool disableWhen,
}) {
final disabledColor = Theme.of(context).disabledColor;
final disabledTheme = Theme.of(context).navigationBarTheme.copyWith(
labelTextStyle: MaterialStatePropertyAll(
Theme.of(context)
.textTheme
.labelSmall
?.copyWith(color: disabledColor),
),
iconTheme: MaterialStatePropertyAll(
Theme.of(context).iconTheme.copyWith(color: disabledColor),
),
);
if (disableWhen) {
return AbsorbPointer(
child: Theme(
data: Theme.of(context).copyWith(navigationBarTheme: disabledTheme),
child: destination,
),
);
}
return destination;
}
}
@@ -1,78 +0,0 @@
import 'package:flutter/material.dart';
import 'package:hydrated_bloc/hydrated_bloc.dart';
import 'package:paperless_mobile/core/repository/label_repository.dart';
import 'package:paperless_mobile/core/repository/saved_view_repository.dart';
import 'package:paperless_mobile/extensions/flutter_extensions.dart';
import 'package:paperless_mobile/features/login/cubit/authentication_cubit.dart';
import 'package:paperless_mobile/features/settings/view/widgets/user_settings_builder.dart';
import 'package:paperless_mobile/generated/l10n/app_localizations.dart';
import 'package:provider/provider.dart';
class VerifyIdentityPage extends StatelessWidget {
const VerifyIdentityPage({super.key});
@override
Widget build(BuildContext context) {
return Material(
child: Scaffold(
appBar: AppBar(
elevation: 0,
backgroundColor: Theme.of(context).colorScheme.background,
title: Text(S.of(context)!.verifyYourIdentity),
),
body: UserAccountBuilder(
builder: (context, settings) {
if (settings == null) {
return const SizedBox.shrink();
}
return Column(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text(S
.of(context)!
.useTheConfiguredBiometricFactorToAuthenticate)
.paddedSymmetrically(horizontal: 16),
const Icon(
Icons.fingerprint,
size: 96,
),
Wrap(
alignment: WrapAlignment.spaceBetween,
runAlignment: WrapAlignment.spaceBetween,
runSpacing: 8,
spacing: 8,
children: [
TextButton(
onPressed: () => _logout(context),
child: Text(
S.of(context)!.disconnect,
style: TextStyle(
color: Theme.of(context).colorScheme.error,
),
),
),
ElevatedButton(
onPressed: () => context
.read<AuthenticationCubit>()
.restoreSessionState(),
child: Text(S.of(context)!.verifyIdentity),
),
],
).padded(16),
],
);
},
),
),
);
}
void _logout(BuildContext context) {
context.read<AuthenticationCubit>().logout();
context.read<LabelRepository>().clear();
context.read<SavedViewRepository>().clear();
HydratedBloc.storage.clear();
}
}
+42 -15
View File
@@ -7,6 +7,7 @@ import 'package:paperless_api/paperless_api.dart';
import 'package:paperless_mobile/core/notifier/document_changed_notifier.dart';
import 'package:paperless_mobile/core/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;
}
+92 -23
View File
@@ -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),
);
+10 -1
View File
@@ -4,8 +4,8 @@ import 'package:paperless_api/paperless_api.dart';
import 'package:paperless_mobile/core/repository/label_repository.dart';
import 'package:paperless_mobile/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) {
+150 -215
View File
@@ -7,23 +7,14 @@ import 'package:paperless_mobile/core/config/hive/hive_config.dart';
import 'package:paperless_mobile/core/database/tables/global_settings.dart';
import 'package:paperless_mobile/core/database/tables/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,
});
+200
View File
@@ -0,0 +1,200 @@
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:paperless_api/paperless_api.dart';
import 'package:paperless_mobile/core/database/tables/local_user_account.dart';
import 'package:paperless_mobile/extensions/flutter_extensions.dart';
import 'package:paperless_mobile/features/app_drawer/view/app_drawer.dart';
import 'package:paperless_mobile/features/document_search/view/sliver_search_bar.dart';
import 'package:paperless_mobile/features/landing/view/widgets/expansion_card.dart';
import 'package:paperless_mobile/features/landing/view/widgets/mime_types_pie_chart.dart';
import 'package:paperless_mobile/features/saved_view/cubit/saved_view_cubit.dart';
import 'package:paperless_mobile/features/saved_view_details/view/saved_view_preview.dart';
import 'package:paperless_mobile/generated/l10n/app_localizations.dart';
import 'package:paperless_mobile/routes/typed/branches/documents_route.dart';
import 'package:paperless_mobile/routes/typed/branches/inbox_route.dart';
class LandingPage extends StatefulWidget {
const LandingPage({super.key});
@override
State<LandingPage> createState() => _LandingPageState();
}
class _LandingPageState extends State<LandingPage> {
final _searchBarHandle = SliverOverlapAbsorberHandle();
@override
Widget build(BuildContext context) {
final currentUser = context.watch<LocalUserAccount>().paperlessUser;
return SafeArea(
child: Scaffold(
drawer: const AppDrawer(),
body: NestedScrollView(
headerSliverBuilder: (context, innerBoxIsScrolled) => [
SliverOverlapAbsorber(
handle: _searchBarHandle,
sliver: SliverSearchBar(
floating: true,
titleText: S.of(context)!.documents,
),
),
],
body: CustomScrollView(
slivers: [
SliverToBoxAdapter(
child: Text(
S.of(context)!.welcomeUser(
currentUser.fullName ?? currentUser.username),
textAlign: TextAlign.center,
style: Theme.of(context)
.textTheme
.displaySmall
?.copyWith(fontSize: 28),
).padded(24),
),
SliverToBoxAdapter(child: _buildStatisticsCard(context)),
if (currentUser.canViewSavedViews) ...[
SliverPadding(
padding: const EdgeInsets.fromLTRB(16, 16, 0, 8),
sliver: SliverToBoxAdapter(
child: Row(
children: [
Icon(
Icons.saved_search,
color: Theme.of(context).colorScheme.primary,
).paddedOnly(right: 8),
Text(
S.of(context)!.views,
style: Theme.of(context).textTheme.titleMedium,
),
],
),
),
),
BlocBuilder<SavedViewCubit, SavedViewState>(
builder: (context, state) {
return state.maybeWhen(
loaded: (savedViews) {
final dashboardViews = savedViews.values
.where((element) => element.showOnDashboard)
.toList();
if (dashboardViews.isEmpty) {
return SliverToBoxAdapter(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(S.of(context)!.noSavedViewOnHomepageHint)
.padded(),
TextButton.icon(
onPressed: () {},
icon: const Icon(Icons.add),
label: Text(S.of(context)!.newView),
)
],
).paddedOnly(left: 16),
);
}
return SliverList.builder(
itemBuilder: (context, index) {
return SavedViewPreview(
savedView: dashboardViews.elementAt(index),
expanded: index == 0,
);
},
itemCount: dashboardViews.length,
);
},
orElse: () => const SliverToBoxAdapter(
child: Center(
child: CircularProgressIndicator(),
),
),
);
},
),
],
],
),
),
),
);
}
Widget _buildStatisticsCard(BuildContext context) {
final currentUser = context.read<LocalUserAccount>().paperlessUser;
return ExpansionCard(
initiallyExpanded: false,
title: Text(
S.of(context)!.statistics,
style: Theme.of(context).textTheme.titleLarge,
),
content: FutureBuilder<PaperlessServerStatisticsModel>(
future: context.read<PaperlessServerStatsApi>().getServerStatistics(),
builder: (context, snapshot) {
if (!snapshot.hasData) {
return const Center(
child: CircularProgressIndicator(),
).paddedOnly(top: 8, bottom: 24);
}
final stats = snapshot.data!;
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Card(
color: Theme.of(context).colorScheme.surfaceVariant,
child: ListTile(
shape: Theme.of(context).cardTheme.shape,
titleTextStyle: Theme.of(context).textTheme.labelLarge,
title: Text(S.of(context)!.documentsInInbox),
onTap: currentUser.canViewInbox
? () => InboxRoute().go(context)
: null,
trailing: Text(
stats.documentsInInbox.toString(),
style: Theme.of(context).textTheme.labelLarge,
),
),
),
Card(
color: Theme.of(context).colorScheme.surfaceVariant,
child: ListTile(
shape: Theme.of(context).cardTheme.shape,
titleTextStyle: Theme.of(context).textTheme.labelLarge,
title: Text(S.of(context)!.totalDocuments),
onTap: currentUser.canViewDocuments
? () {
DocumentsRoute().go(context);
}
: null,
trailing: Text(
stats.documentsTotal.toString(),
style: Theme.of(context).textTheme.labelLarge,
),
),
),
Card(
color: Theme.of(context).colorScheme.surfaceVariant,
child: ListTile(
shape: Theme.of(context).cardTheme.shape,
titleTextStyle: Theme.of(context).textTheme.labelLarge,
title: Text(S.of(context)!.totalCharacters),
trailing: Text(
stats.totalChars.toString(),
style: Theme.of(context).textTheme.labelLarge,
),
),
),
AspectRatio(
aspectRatio: 1.3,
child: SizedBox(
width: 300,
child: MimeTypesPieChart(statistics: stats),
),
),
],
).padded(16);
},
),
);
}
}

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