mirror of
https://github.com/Xevion/paperless-mobile.git
synced 2025-12-09 04:07:57 -06:00
Merge branch 'main' into bugfix/ios-delete-temporary-directory
This commit is contained in:
1
.gitignore
vendored
1
.gitignore
vendored
@@ -22,6 +22,7 @@ android/key.properties
|
||||
# VS Code which you may wish to be included in version control, so this line
|
||||
# is commented out by default.
|
||||
.vscode/
|
||||
*.code-workspace
|
||||
|
||||
# Flutter/Dart/Pub related
|
||||
**/doc/api/
|
||||
|
||||
@@ -227,3 +227,6 @@ Here are some impressions from the app!
|
||||
|
||||
Made with [contrib.rocks](https://contrib.rocks).
|
||||
|
||||
## Troubleshooting
|
||||
#### Suggestions are not selectable in any of the label form fields
|
||||
This is a known issue and it has to do with accessibility features of Android. Password managers such as Bitwarden often caused this issue to occur. Luckily, this can be resolved by turning off the accessibility features in these apps.
|
||||
|
||||
@@ -68,11 +68,11 @@ android {
|
||||
storePassword keystoreProperties['storePassword']
|
||||
}
|
||||
}
|
||||
buildTypes {
|
||||
release {
|
||||
signingConfig signingConfigs.release
|
||||
}
|
||||
}
|
||||
buildTypes {
|
||||
release {
|
||||
signingConfig signingConfigs.release
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
|
||||
@@ -35,7 +35,7 @@ PODS:
|
||||
- DKPhotoGallery/Resource (0.0.17):
|
||||
- SDWebImage
|
||||
- SwiftyGif
|
||||
- edge_detection (1.0.9):
|
||||
- edge_detection (1.1.1):
|
||||
- Flutter
|
||||
- WeScan
|
||||
- file_picker (0.0.1):
|
||||
@@ -44,6 +44,8 @@ PODS:
|
||||
- Flutter (1.0.0)
|
||||
- flutter_keyboard_visibility (0.0.1):
|
||||
- Flutter
|
||||
- flutter_local_notifications (0.0.1):
|
||||
- Flutter
|
||||
- flutter_native_splash (0.0.1):
|
||||
- Flutter
|
||||
- fluttertoast (0.0.2):
|
||||
@@ -56,10 +58,13 @@ PODS:
|
||||
- Flutter
|
||||
- local_auth_ios (0.0.1):
|
||||
- Flutter
|
||||
- open_filex (0.0.2):
|
||||
- Flutter
|
||||
- package_info_plus (0.4.5):
|
||||
- Flutter
|
||||
- path_provider_ios (0.0.1):
|
||||
- path_provider_foundation (0.0.1):
|
||||
- Flutter
|
||||
- FlutterMacOS
|
||||
- pdfx (1.0.0):
|
||||
- Flutter
|
||||
- permission_handler_apple (9.0.4):
|
||||
@@ -72,8 +77,9 @@ PODS:
|
||||
- SDWebImage/Core (5.13.5)
|
||||
- share_plus (0.0.1):
|
||||
- Flutter
|
||||
- shared_preferences_ios (0.0.1):
|
||||
- shared_preferences_foundation (0.0.1):
|
||||
- Flutter
|
||||
- FlutterMacOS
|
||||
- sqflite (0.0.2):
|
||||
- Flutter
|
||||
- FMDB (>= 2.7.5)
|
||||
@@ -90,17 +96,19 @@ DEPENDENCIES:
|
||||
- file_picker (from `.symlinks/plugins/file_picker/ios`)
|
||||
- Flutter (from `Flutter`)
|
||||
- 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`)
|
||||
- fluttertoast (from `.symlinks/plugins/fluttertoast/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_ios (from `.symlinks/plugins/path_provider_ios/ios`)
|
||||
- path_provider_foundation (from `.symlinks/plugins/path_provider_foundation/darwin`)
|
||||
- pdfx (from `.symlinks/plugins/pdfx/ios`)
|
||||
- permission_handler_apple (from `.symlinks/plugins/permission_handler_apple/ios`)
|
||||
- receive_sharing_intent (from `.symlinks/plugins/receive_sharing_intent/ios`)
|
||||
- share_plus (from `.symlinks/plugins/share_plus/ios`)
|
||||
- shared_preferences_ios (from `.symlinks/plugins/shared_preferences_ios/ios`)
|
||||
- shared_preferences_foundation (from `.symlinks/plugins/shared_preferences_foundation/darwin`)
|
||||
- sqflite (from `.symlinks/plugins/sqflite/ios`)
|
||||
- url_launcher_ios (from `.symlinks/plugins/url_launcher_ios/ios`)
|
||||
|
||||
@@ -128,6 +136,8 @@ EXTERNAL SOURCES:
|
||||
:path: Flutter
|
||||
flutter_keyboard_visibility:
|
||||
:path: ".symlinks/plugins/flutter_keyboard_visibility/ios"
|
||||
flutter_local_notifications:
|
||||
:path: ".symlinks/plugins/flutter_local_notifications/ios"
|
||||
flutter_native_splash:
|
||||
:path: ".symlinks/plugins/flutter_native_splash/ios"
|
||||
fluttertoast:
|
||||
@@ -136,10 +146,12 @@ EXTERNAL SOURCES:
|
||||
:path: ".symlinks/plugins/integration_test/ios"
|
||||
local_auth_ios:
|
||||
:path: ".symlinks/plugins/local_auth_ios/ios"
|
||||
open_filex:
|
||||
:path: ".symlinks/plugins/open_filex/ios"
|
||||
package_info_plus:
|
||||
:path: ".symlinks/plugins/package_info_plus/ios"
|
||||
path_provider_ios:
|
||||
:path: ".symlinks/plugins/path_provider_ios/ios"
|
||||
path_provider_foundation:
|
||||
:path: ".symlinks/plugins/path_provider_foundation/darwin"
|
||||
pdfx:
|
||||
:path: ".symlinks/plugins/pdfx/ios"
|
||||
permission_handler_apple:
|
||||
@@ -148,8 +160,8 @@ EXTERNAL SOURCES:
|
||||
:path: ".symlinks/plugins/receive_sharing_intent/ios"
|
||||
share_plus:
|
||||
:path: ".symlinks/plugins/share_plus/ios"
|
||||
shared_preferences_ios:
|
||||
:path: ".symlinks/plugins/shared_preferences_ios/ios"
|
||||
shared_preferences_foundation:
|
||||
:path: ".symlinks/plugins/shared_preferences_foundation/darwin"
|
||||
sqflite:
|
||||
:path: ".symlinks/plugins/sqflite/ios"
|
||||
url_launcher_ios:
|
||||
@@ -160,28 +172,30 @@ SPEC CHECKSUMS:
|
||||
device_info_plus: e5c5da33f982a436e103237c0c85f9031142abed
|
||||
DKImagePickerController: b512c28220a2b8ac7419f21c491fc8534b7601ac
|
||||
DKPhotoGallery: fdfad5125a9fdda9cc57df834d49df790dbb4179
|
||||
edge_detection: 9bc5ee35073b5a17c0b3b679908f01017ce3062a
|
||||
file_picker: 3e6c3790de664ccf9b882732d9db5eaf6b8d4eb1
|
||||
edge_detection: fa02aa120e00d87ada0ca2430b6c6087a501b1e9
|
||||
file_picker: ce3938a0df3cc1ef404671531facef740d03f920
|
||||
Flutter: f04841e97a9d0b0a8025694d0796dd46242b2854
|
||||
flutter_keyboard_visibility: 0339d06371254c3eb25eeb90ba8d17dca8f9c069
|
||||
flutter_local_notifications: 0c0b1ae97e741e1521e4c1629a459d04b9aec743
|
||||
flutter_native_splash: 52501b97d1c0a5f898d687f1646226c1f93c56ef
|
||||
fluttertoast: 74526702fea2c060ea55dde75895b7e1bde1c86b
|
||||
fluttertoast: eb263d302cc92e04176c053d2385237e9f43fad0
|
||||
FMDB: 2ce00b547f966261cd18927a3ddb07cb6f3db82a
|
||||
integration_test: a1e7d09bd98eca2fc37aefd79d4f41ad37bdbbe5
|
||||
local_auth_ios: 0d333dde7780f669e66f19d2ff6005f3ea84008d
|
||||
open_filex: 6e26e659846ec990262224a12ef1c528bb4edbe4
|
||||
package_info_plus: 6c92f08e1f853dc01228d6f553146438dafcd14e
|
||||
path_provider_ios: 14f3d2fd28c4fdb42f44e0f751d12861c43cee02
|
||||
path_provider_foundation: 37748e03f12783f9de2cb2c4eadfaa25fe6d4852
|
||||
pdfx: 7b876b09de8b7a0bf444a4f82b439ffcff4ee1ec
|
||||
permission_handler_apple: 44366e37eaf29454a1e7b1b7d736c2cceaeb17ce
|
||||
ReachabilitySwift: 985039c6f7b23a1da463388634119492ff86c825
|
||||
receive_sharing_intent: c0d87310754e74c0f9542947e7cbdf3a0335a3b1
|
||||
SDWebImage: 23d714cd599354ee7906dbae26dff89b421c4370
|
||||
share_plus: 056a1e8ac890df3e33cb503afffaf1e9b4fbae68
|
||||
shared_preferences_ios: 548a61f8053b9b8a49ac19c1ffbc8b92c50d68ad
|
||||
shared_preferences_foundation: 297b3ebca31b34ec92be11acd7fb0ba932c822ca
|
||||
sqflite: 6d358c025f5b867b29ed92fc697fd34924e11904
|
||||
SwiftyGif: 6c3eafd0ce693cad58bb63d2b2fb9bacb8552780
|
||||
Toast: 91b396c56ee72a5790816f40d3a94dd357abc196
|
||||
url_launcher_ios: 839c58cdb4279282219f5e248c3321761ff3c4de
|
||||
url_launcher_ios: ae1517e5e344f5544fb090b079e11f399dfbe4d2
|
||||
WeScan: fed582f6c38014d529afb5aa9ffd1bad38fc72b7
|
||||
|
||||
PODFILE CHECKSUM: ef19549a9bc3046e7bb7d2fab4d021637c0c58a3
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
archiveVersion = 1;
|
||||
classes = {
|
||||
};
|
||||
objectVersion = 51;
|
||||
objectVersion = 54;
|
||||
objects = {
|
||||
|
||||
/* Begin PBXBuildFile section */
|
||||
@@ -321,10 +321,12 @@
|
||||
};
|
||||
3B06AD1E1E4923F5004D2608 /* Thin Binary */ = {
|
||||
isa = PBXShellScriptBuildPhase;
|
||||
alwaysOutOfDate = 1;
|
||||
buildActionMask = 2147483647;
|
||||
files = (
|
||||
);
|
||||
inputPaths = (
|
||||
"${TARGET_BUILD_DIR}/${INFOPLIST_PATH}",
|
||||
);
|
||||
name = "Thin Binary";
|
||||
outputPaths = (
|
||||
@@ -335,6 +337,7 @@
|
||||
};
|
||||
9740EEB61CF901F6004384FC /* Run Script */ = {
|
||||
isa = PBXShellScriptBuildPhase;
|
||||
alwaysOutOfDate = 1;
|
||||
buildActionMask = 2147483647;
|
||||
files = (
|
||||
);
|
||||
|
||||
@@ -65,5 +65,7 @@
|
||||
<false/>
|
||||
<key>CADisableMinimumFrameDurationOnPhone</key>
|
||||
<true/>
|
||||
</dict>
|
||||
<key>UIApplicationSupportsIndirectInputEvents</key>
|
||||
<true/>
|
||||
</dict>
|
||||
</plist>
|
||||
|
||||
7
lib/constants.dart
Normal file
7
lib/constants.dart
Normal file
@@ -0,0 +1,7 @@
|
||||
import 'package:device_info_plus/device_info_plus.dart';
|
||||
import 'package:package_info_plus/package_info_plus.dart';
|
||||
|
||||
// Globally accessible variables which are definitely initialized after main().
|
||||
late final PackageInfo packageInfo;
|
||||
late final AndroidDeviceInfo? androidInfo;
|
||||
late final IosDeviceInfo? iosInfo;
|
||||
@@ -1,7 +1,6 @@
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
import 'package:paperless_api/paperless_api.dart';
|
||||
import 'package:paperless_mobile/core/bloc/paperless_server_information_state.dart';
|
||||
import 'package:paperless_mobile/core/security/session_manager.dart';
|
||||
|
||||
class PaperlessServerInformationCubit
|
||||
extends Cubit<PaperlessServerInformationState> {
|
||||
|
||||
@@ -1,11 +0,0 @@
|
||||
import 'package:paperless_api/paperless_api.dart';
|
||||
|
||||
class PaperlessStatisticsState {
|
||||
final bool isLoaded;
|
||||
final PaperlessServerStatisticsModel? statistics;
|
||||
|
||||
PaperlessStatisticsState({
|
||||
required this.isLoaded,
|
||||
this.statistics,
|
||||
});
|
||||
}
|
||||
54
lib/core/notifier/document_changed_notifier.dart
Normal file
54
lib/core/notifier/document_changed_notifier.dart
Normal file
@@ -0,0 +1,54 @@
|
||||
import 'dart:async';
|
||||
import 'dart:developer';
|
||||
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:paperless_api/paperless_api.dart';
|
||||
import 'package:rxdart/subjects.dart';
|
||||
|
||||
typedef DocumentChangedCallback = void Function(DocumentModel document);
|
||||
|
||||
class DocumentChangedNotifier {
|
||||
final Subject<DocumentModel> _updated = PublishSubject();
|
||||
final Subject<DocumentModel> _deleted = PublishSubject();
|
||||
|
||||
final Map<dynamic, List<StreamSubscription>> _subscribers = {};
|
||||
|
||||
void notifyUpdated(DocumentModel updated) {
|
||||
debugPrint("Notifying updated document ${updated.id}");
|
||||
_updated.add(updated);
|
||||
}
|
||||
|
||||
void notifyDeleted(DocumentModel deleted) {
|
||||
debugPrint("Notifying deleted document ${deleted.id}");
|
||||
_deleted.add(deleted);
|
||||
}
|
||||
|
||||
void subscribe(
|
||||
dynamic subscriber, {
|
||||
DocumentChangedCallback? onUpdated,
|
||||
DocumentChangedCallback? onDeleted,
|
||||
}) {
|
||||
_subscribers.putIfAbsent(
|
||||
subscriber,
|
||||
() => [
|
||||
_updated.listen((value) {
|
||||
onUpdated?.call(value);
|
||||
}),
|
||||
_deleted.listen((value) {
|
||||
onDeleted?.call(value);
|
||||
}),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
void unsubscribe(dynamic subscriber) {
|
||||
_subscribers[subscriber]?.forEach((element) {
|
||||
element.cancel();
|
||||
});
|
||||
}
|
||||
|
||||
void close() {
|
||||
_updated.close();
|
||||
_deleted.close();
|
||||
}
|
||||
}
|
||||
@@ -1,30 +1,30 @@
|
||||
import 'package:hydrated_bloc/hydrated_bloc.dart';
|
||||
import 'package:paperless_mobile/core/repository/state/repository_state.dart';
|
||||
import 'package:paperless_mobile/core/repository/state/indexed_repository_state.dart';
|
||||
import 'package:rxdart/subjects.dart';
|
||||
|
||||
///
|
||||
/// Base repository class which all repositories should implement
|
||||
///
|
||||
abstract class BaseRepository<State extends RepositoryState, Type>
|
||||
extends Cubit<State> with HydratedMixin {
|
||||
final State _initialState;
|
||||
abstract class BaseRepository<T> extends Cubit<IndexedRepositoryState<T>>
|
||||
with HydratedMixin {
|
||||
final IndexedRepositoryState<T> _initialState;
|
||||
|
||||
BaseRepository(this._initialState) : super(_initialState) {
|
||||
hydrate();
|
||||
}
|
||||
|
||||
Stream<State?> get values =>
|
||||
Stream<IndexedRepositoryState<T>?> get values =>
|
||||
BehaviorSubject.seeded(state)..addStream(super.stream);
|
||||
|
||||
State? get current => state;
|
||||
IndexedRepositoryState<T>? get current => state;
|
||||
|
||||
bool get isInitialized => state.hasLoaded;
|
||||
|
||||
Future<Type> create(Type object);
|
||||
Future<Type?> find(int id);
|
||||
Future<Iterable<Type>> findAll([Iterable<int>? ids]);
|
||||
Future<Type> update(Type object);
|
||||
Future<int> delete(Type object);
|
||||
Future<T> create(T object);
|
||||
Future<T?> find(int id);
|
||||
Future<Iterable<T>> findAll([Iterable<int>? ids]);
|
||||
Future<T> update(T object);
|
||||
Future<int> delete(T object);
|
||||
|
||||
@override
|
||||
Future<void> clear() async {
|
||||
|
||||
@@ -3,10 +3,8 @@ import 'dart:async';
|
||||
import 'package:paperless_api/paperless_api.dart';
|
||||
import 'package:paperless_mobile/core/repository/label_repository.dart';
|
||||
import 'package:paperless_mobile/core/repository/state/impl/correspondent_repository_state.dart';
|
||||
import 'package:paperless_mobile/core/repository/state/repository_state.dart';
|
||||
|
||||
class CorrespondentRepositoryImpl
|
||||
extends LabelRepository<Correspondent, CorrespondentRepositoryState> {
|
||||
class CorrespondentRepositoryImpl extends LabelRepository<Correspondent> {
|
||||
final PaperlessLabelsApi _api;
|
||||
|
||||
CorrespondentRepositoryImpl(this._api)
|
||||
@@ -15,7 +13,7 @@ class CorrespondentRepositoryImpl
|
||||
@override
|
||||
Future<Correspondent> create(Correspondent correspondent) async {
|
||||
final created = await _api.saveCorrespondent(correspondent);
|
||||
final updatedState = {...state.values}
|
||||
final updatedState = {...state.values ?? {}}
|
||||
..putIfAbsent(created.id!, () => created);
|
||||
emit(CorrespondentRepositoryState(values: updatedState, hasLoaded: true));
|
||||
return created;
|
||||
@@ -24,7 +22,7 @@ class CorrespondentRepositoryImpl
|
||||
@override
|
||||
Future<int> delete(Correspondent correspondent) async {
|
||||
await _api.deleteCorrespondent(correspondent);
|
||||
final updatedState = {...state.values}
|
||||
final updatedState = {...state.values ?? {}}
|
||||
..removeWhere((k, v) => k == correspondent.id);
|
||||
emit(CorrespondentRepositoryState(values: updatedState, hasLoaded: true));
|
||||
return correspondent.id!;
|
||||
@@ -34,7 +32,7 @@ class CorrespondentRepositoryImpl
|
||||
Future<Correspondent?> find(int id) async {
|
||||
final correspondent = await _api.getCorrespondent(id);
|
||||
if (correspondent != null) {
|
||||
final updatedState = {...state.values}..[id] = correspondent;
|
||||
final updatedState = {...state.values ?? {}}..[id] = correspondent;
|
||||
emit(CorrespondentRepositoryState(values: updatedState, hasLoaded: true));
|
||||
return correspondent;
|
||||
}
|
||||
@@ -44,7 +42,7 @@ class CorrespondentRepositoryImpl
|
||||
@override
|
||||
Future<Iterable<Correspondent>> findAll([Iterable<int>? ids]) async {
|
||||
final correspondents = await _api.getCorrespondents(ids);
|
||||
final updatedState = {...state.values}
|
||||
final updatedState = {...state.values ?? {}}
|
||||
..addEntries(correspondents.map((e) => MapEntry(e.id!, e)));
|
||||
emit(CorrespondentRepositoryState(values: updatedState, hasLoaded: true));
|
||||
return correspondents;
|
||||
@@ -53,7 +51,8 @@ class CorrespondentRepositoryImpl
|
||||
@override
|
||||
Future<Correspondent> update(Correspondent correspondent) async {
|
||||
final updated = await _api.updateCorrespondent(correspondent);
|
||||
final updatedState = {...state.values}..update(updated.id!, (_) => updated);
|
||||
final updatedState = {...state.values ?? {}}
|
||||
..update(updated.id!, (_) => updated);
|
||||
emit(CorrespondentRepositoryState(values: updatedState, hasLoaded: true));
|
||||
return updated;
|
||||
}
|
||||
@@ -64,7 +63,7 @@ class CorrespondentRepositoryImpl
|
||||
}
|
||||
|
||||
@override
|
||||
Map<String, dynamic> toJson(CorrespondentRepositoryState state) {
|
||||
Map<String, dynamic> toJson(covariant CorrespondentRepositoryState state) {
|
||||
return state.toJson();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,10 +1,8 @@
|
||||
import 'package:paperless_api/paperless_api.dart';
|
||||
import 'package:paperless_mobile/core/repository/label_repository.dart';
|
||||
import 'package:paperless_mobile/core/repository/state/impl/document_type_repository_state.dart';
|
||||
import 'package:rxdart/rxdart.dart' show BehaviorSubject;
|
||||
|
||||
class DocumentTypeRepositoryImpl
|
||||
extends LabelRepository<DocumentType, DocumentTypeRepositoryState> {
|
||||
class DocumentTypeRepositoryImpl extends LabelRepository<DocumentType> {
|
||||
final PaperlessLabelsApi _api;
|
||||
|
||||
DocumentTypeRepositoryImpl(this._api)
|
||||
@@ -13,7 +11,7 @@ class DocumentTypeRepositoryImpl
|
||||
@override
|
||||
Future<DocumentType> create(DocumentType documentType) async {
|
||||
final created = await _api.saveDocumentType(documentType);
|
||||
final updatedState = {...state.values}
|
||||
final updatedState = {...state.values ?? {}}
|
||||
..putIfAbsent(created.id!, () => created);
|
||||
emit(DocumentTypeRepositoryState(values: updatedState, hasLoaded: true));
|
||||
return created;
|
||||
@@ -22,7 +20,7 @@ class DocumentTypeRepositoryImpl
|
||||
@override
|
||||
Future<int> delete(DocumentType documentType) async {
|
||||
await _api.deleteDocumentType(documentType);
|
||||
final updatedState = {...state.values}
|
||||
final updatedState = {...state.values ?? {}}
|
||||
..removeWhere((k, v) => k == documentType.id);
|
||||
emit(DocumentTypeRepositoryState(values: updatedState, hasLoaded: true));
|
||||
return documentType.id!;
|
||||
@@ -32,7 +30,7 @@ class DocumentTypeRepositoryImpl
|
||||
Future<DocumentType?> find(int id) async {
|
||||
final documentType = await _api.getDocumentType(id);
|
||||
if (documentType != null) {
|
||||
final updatedState = {...state.values}..[id] = documentType;
|
||||
final updatedState = {...state.values ?? {}}..[id] = documentType;
|
||||
emit(DocumentTypeRepositoryState(values: updatedState, hasLoaded: true));
|
||||
return documentType;
|
||||
}
|
||||
@@ -42,7 +40,7 @@ class DocumentTypeRepositoryImpl
|
||||
@override
|
||||
Future<Iterable<DocumentType>> findAll([Iterable<int>? ids]) async {
|
||||
final documentTypes = await _api.getDocumentTypes(ids);
|
||||
final updatedState = {...state.values}
|
||||
final updatedState = {...state.values ?? {}}
|
||||
..addEntries(documentTypes.map((e) => MapEntry(e.id!, e)));
|
||||
emit(DocumentTypeRepositoryState(values: updatedState, hasLoaded: true));
|
||||
return documentTypes;
|
||||
@@ -51,7 +49,8 @@ class DocumentTypeRepositoryImpl
|
||||
@override
|
||||
Future<DocumentType> update(DocumentType documentType) async {
|
||||
final updated = await _api.updateDocumentType(documentType);
|
||||
final updatedState = {...state.values}..update(updated.id!, (_) => updated);
|
||||
final updatedState = {...state.values ?? {}}
|
||||
..update(updated.id!, (_) => updated);
|
||||
emit(DocumentTypeRepositoryState(values: updatedState, hasLoaded: true));
|
||||
return updated;
|
||||
}
|
||||
@@ -62,7 +61,7 @@ class DocumentTypeRepositoryImpl
|
||||
}
|
||||
|
||||
@override
|
||||
Map<String, dynamic> toJson(DocumentTypeRepositoryState state) {
|
||||
Map<String, dynamic> toJson(covariant DocumentTypeRepositoryState state) {
|
||||
return state.toJson();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -10,7 +10,7 @@ class SavedViewRepositoryImpl extends SavedViewRepository {
|
||||
@override
|
||||
Future<SavedView> create(SavedView object) async {
|
||||
final created = await _api.save(object);
|
||||
final updatedState = {...state.values}
|
||||
final updatedState = {...state.values ?? {}}
|
||||
..putIfAbsent(created.id!, () => created);
|
||||
emit(SavedViewRepositoryState(values: updatedState, hasLoaded: true));
|
||||
return created;
|
||||
@@ -19,7 +19,7 @@ class SavedViewRepositoryImpl extends SavedViewRepository {
|
||||
@override
|
||||
Future<int> delete(SavedView view) async {
|
||||
await _api.delete(view);
|
||||
final updatedState = {...state.values}..remove(view.id);
|
||||
final updatedState = {...state.values ?? {}}..remove(view.id);
|
||||
emit(SavedViewRepositoryState(values: updatedState, hasLoaded: true));
|
||||
return view.id!;
|
||||
}
|
||||
@@ -27,7 +27,7 @@ class SavedViewRepositoryImpl extends SavedViewRepository {
|
||||
@override
|
||||
Future<SavedView?> find(int id) async {
|
||||
final found = await _api.find(id);
|
||||
final updatedState = {...state.values}
|
||||
final updatedState = {...state.values ?? {}}
|
||||
..update(id, (_) => found, ifAbsent: () => found);
|
||||
emit(SavedViewRepositoryState(values: updatedState, hasLoaded: true));
|
||||
return found;
|
||||
@@ -37,7 +37,7 @@ class SavedViewRepositoryImpl extends SavedViewRepository {
|
||||
Future<Iterable<SavedView>> findAll([Iterable<int>? ids]) async {
|
||||
final found = await _api.findAll(ids);
|
||||
final updatedState = {
|
||||
...state.values,
|
||||
...state.values ?? {},
|
||||
...{for (final view in found) view.id!: view},
|
||||
};
|
||||
emit(SavedViewRepositoryState(values: updatedState, hasLoaded: true));
|
||||
@@ -56,7 +56,7 @@ class SavedViewRepositoryImpl extends SavedViewRepository {
|
||||
}
|
||||
|
||||
@override
|
||||
Map<String, dynamic> toJson(SavedViewRepositoryState state) {
|
||||
Map<String, dynamic> toJson(covariant SavedViewRepositoryState state) {
|
||||
return state.toJson();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,8 +3,7 @@ import 'package:paperless_mobile/core/repository/label_repository.dart';
|
||||
import 'package:paperless_mobile/core/repository/state/impl/storage_path_repository_state.dart';
|
||||
import 'package:rxdart/rxdart.dart' show BehaviorSubject;
|
||||
|
||||
class StoragePathRepositoryImpl
|
||||
extends LabelRepository<StoragePath, StoragePathRepositoryState> {
|
||||
class StoragePathRepositoryImpl extends LabelRepository<StoragePath> {
|
||||
final PaperlessLabelsApi _api;
|
||||
|
||||
StoragePathRepositoryImpl(this._api)
|
||||
@@ -13,7 +12,7 @@ class StoragePathRepositoryImpl
|
||||
@override
|
||||
Future<StoragePath> create(StoragePath storagePath) async {
|
||||
final created = await _api.saveStoragePath(storagePath);
|
||||
final updatedState = {...state.values}
|
||||
final updatedState = {...state.values ?? {}}
|
||||
..putIfAbsent(created.id!, () => created);
|
||||
emit(StoragePathRepositoryState(values: updatedState, hasLoaded: true));
|
||||
return created;
|
||||
@@ -22,7 +21,7 @@ class StoragePathRepositoryImpl
|
||||
@override
|
||||
Future<int> delete(StoragePath storagePath) async {
|
||||
await _api.deleteStoragePath(storagePath);
|
||||
final updatedState = {...state.values}
|
||||
final updatedState = {...state.values ?? {}}
|
||||
..removeWhere((k, v) => k == storagePath.id);
|
||||
emit(StoragePathRepositoryState(values: updatedState, hasLoaded: true));
|
||||
return storagePath.id!;
|
||||
@@ -32,7 +31,7 @@ class StoragePathRepositoryImpl
|
||||
Future<StoragePath?> find(int id) async {
|
||||
final storagePath = await _api.getStoragePath(id);
|
||||
if (storagePath != null) {
|
||||
final updatedState = {...state.values}..[id] = storagePath;
|
||||
final updatedState = {...state.values ?? {}}..[id] = storagePath;
|
||||
emit(StoragePathRepositoryState(values: updatedState, hasLoaded: true));
|
||||
return storagePath;
|
||||
}
|
||||
@@ -42,7 +41,7 @@ class StoragePathRepositoryImpl
|
||||
@override
|
||||
Future<Iterable<StoragePath>> findAll([Iterable<int>? ids]) async {
|
||||
final storagePaths = await _api.getStoragePaths(ids);
|
||||
final updatedState = {...state.values}
|
||||
final updatedState = {...state.values ?? {}}
|
||||
..addEntries(storagePaths.map((e) => MapEntry(e.id!, e)));
|
||||
emit(StoragePathRepositoryState(values: updatedState, hasLoaded: true));
|
||||
return storagePaths;
|
||||
@@ -51,7 +50,8 @@ class StoragePathRepositoryImpl
|
||||
@override
|
||||
Future<StoragePath> update(StoragePath storagePath) async {
|
||||
final updated = await _api.updateStoragePath(storagePath);
|
||||
final updatedState = {...state.values}..update(updated.id!, (_) => updated);
|
||||
final updatedState = {...state.values ?? {}}
|
||||
..update(updated.id!, (_) => updated);
|
||||
emit(StoragePathRepositoryState(values: updatedState, hasLoaded: true));
|
||||
return updated;
|
||||
}
|
||||
@@ -62,7 +62,7 @@ class StoragePathRepositoryImpl
|
||||
}
|
||||
|
||||
@override
|
||||
Map<String, dynamic> toJson(StoragePathRepositoryState state) {
|
||||
Map<String, dynamic> toJson(covariant StoragePathRepositoryState state) {
|
||||
return state.toJson();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,10 +1,8 @@
|
||||
import 'package:paperless_api/paperless_api.dart';
|
||||
import 'package:paperless_mobile/core/repository/label_repository.dart';
|
||||
import 'package:paperless_mobile/core/repository/state/impl/correspondent_repository_state.dart';
|
||||
import 'package:paperless_mobile/core/repository/state/impl/tag_repository_state.dart';
|
||||
import 'package:paperless_mobile/core/repository/state/repository_state.dart';
|
||||
|
||||
class TagRepositoryImpl extends LabelRepository<Tag, TagRepositoryState> {
|
||||
class TagRepositoryImpl extends LabelRepository<Tag> {
|
||||
final PaperlessLabelsApi _api;
|
||||
|
||||
TagRepositoryImpl(this._api) : super(const TagRepositoryState());
|
||||
@@ -12,7 +10,7 @@ class TagRepositoryImpl extends LabelRepository<Tag, TagRepositoryState> {
|
||||
@override
|
||||
Future<Tag> create(Tag object) async {
|
||||
final created = await _api.saveTag(object);
|
||||
final updatedState = {...state.values}
|
||||
final updatedState = {...state.values ?? {}}
|
||||
..putIfAbsent(created.id!, () => created);
|
||||
emit(TagRepositoryState(values: updatedState, hasLoaded: true));
|
||||
return created;
|
||||
@@ -21,7 +19,8 @@ class TagRepositoryImpl extends LabelRepository<Tag, TagRepositoryState> {
|
||||
@override
|
||||
Future<int> delete(Tag tag) async {
|
||||
await _api.deleteTag(tag);
|
||||
final updatedState = {...state.values}..removeWhere((k, v) => k == tag.id);
|
||||
final updatedState = {...state.values ?? {}}
|
||||
..removeWhere((k, v) => k == tag.id);
|
||||
emit(TagRepositoryState(values: updatedState, hasLoaded: true));
|
||||
return tag.id!;
|
||||
}
|
||||
@@ -30,7 +29,7 @@ class TagRepositoryImpl extends LabelRepository<Tag, TagRepositoryState> {
|
||||
Future<Tag?> find(int id) async {
|
||||
final tag = await _api.getTag(id);
|
||||
if (tag != null) {
|
||||
final updatedState = {...state.values}..[id] = tag;
|
||||
final updatedState = {...state.values ?? {}}..[id] = tag;
|
||||
emit(TagRepositoryState(values: updatedState, hasLoaded: true));
|
||||
return tag;
|
||||
}
|
||||
@@ -40,7 +39,7 @@ class TagRepositoryImpl extends LabelRepository<Tag, TagRepositoryState> {
|
||||
@override
|
||||
Future<Iterable<Tag>> findAll([Iterable<int>? ids]) async {
|
||||
final tags = await _api.getTags(ids);
|
||||
final updatedState = {...state.values}
|
||||
final updatedState = {...state.values ?? {}}
|
||||
..addEntries(tags.map((e) => MapEntry(e.id!, e)));
|
||||
emit(TagRepositoryState(values: updatedState, hasLoaded: true));
|
||||
return tags;
|
||||
@@ -49,7 +48,8 @@ class TagRepositoryImpl extends LabelRepository<Tag, TagRepositoryState> {
|
||||
@override
|
||||
Future<Tag> update(Tag tag) async {
|
||||
final updated = await _api.updateTag(tag);
|
||||
final updatedState = {...state.values}..update(updated.id!, (_) => updated);
|
||||
final updatedState = {...state.values ?? {}}
|
||||
..update(updated.id!, (_) => updated);
|
||||
emit(TagRepositoryState(values: updatedState, hasLoaded: true));
|
||||
return updated;
|
||||
}
|
||||
@@ -60,7 +60,7 @@ class TagRepositoryImpl extends LabelRepository<Tag, TagRepositoryState> {
|
||||
}
|
||||
|
||||
@override
|
||||
Map<String, dynamic>? toJson(TagRepositoryState state) {
|
||||
Map<String, dynamic>? toJson(covariant TagRepositoryState state) {
|
||||
return state.toJson();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,8 +1,7 @@
|
||||
import 'package:paperless_api/paperless_api.dart';
|
||||
import 'package:paperless_mobile/core/repository/base_repository.dart';
|
||||
import 'package:paperless_mobile/core/repository/state/repository_state.dart';
|
||||
import 'package:paperless_mobile/core/repository/state/indexed_repository_state.dart';
|
||||
|
||||
abstract class LabelRepository<T extends Label, State extends RepositoryState>
|
||||
extends BaseRepository<State, T> {
|
||||
LabelRepository(State initial) : super(initial);
|
||||
abstract class LabelRepository<T extends Label> extends BaseRepository<T> {
|
||||
LabelRepository(IndexedRepositoryState<T> initial) : super(initial);
|
||||
}
|
||||
|
||||
@@ -17,20 +17,16 @@ class LabelRepositoriesProvider extends StatelessWidget {
|
||||
return MultiRepositoryProvider(
|
||||
providers: [
|
||||
RepositoryProvider(
|
||||
create: (context) => context.read<
|
||||
LabelRepository<Correspondent, CorrespondentRepositoryState>>(),
|
||||
create: (context) => context.read<LabelRepository<Correspondent>>(),
|
||||
),
|
||||
RepositoryProvider(
|
||||
create: (context) => context.read<
|
||||
LabelRepository<DocumentType, DocumentTypeRepositoryState>>(),
|
||||
create: (context) => context.read<LabelRepository<DocumentType>>(),
|
||||
),
|
||||
RepositoryProvider(
|
||||
create: (context) => context
|
||||
.read<LabelRepository<StoragePath, StoragePathRepositoryState>>(),
|
||||
create: (context) => context.read<LabelRepository<StoragePath>>(),
|
||||
),
|
||||
RepositoryProvider(
|
||||
create: (context) =>
|
||||
context.read<LabelRepository<Tag, TagRepositoryState>>(),
|
||||
create: (context) => context.read<LabelRepository<Tag>>(),
|
||||
),
|
||||
],
|
||||
child: child,
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import 'package:paperless_api/paperless_api.dart';
|
||||
import 'package:paperless_mobile/core/repository/base_repository.dart';
|
||||
import 'package:paperless_mobile/core/repository/state/impl/saved_view_repository_state.dart';
|
||||
import 'package:paperless_mobile/core/repository/state/indexed_repository_state.dart';
|
||||
|
||||
abstract class SavedViewRepository
|
||||
extends BaseRepository<SavedViewRepositoryState, SavedView> {
|
||||
abstract class SavedViewRepository extends BaseRepository<SavedView> {
|
||||
SavedViewRepository(super.initialState);
|
||||
}
|
||||
|
||||
@@ -1,13 +1,13 @@
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:json_annotation/json_annotation.dart';
|
||||
import 'package:paperless_api/paperless_api.dart';
|
||||
import 'package:paperless_mobile/core/repository/state/repository_state.dart';
|
||||
import 'package:paperless_mobile/core/repository/state/indexed_repository_state.dart';
|
||||
|
||||
part 'correspondent_repository_state.g.dart';
|
||||
|
||||
@JsonSerializable()
|
||||
class CorrespondentRepositoryState
|
||||
extends RepositoryState<Map<int, Correspondent>> {
|
||||
extends IndexedRepositoryState<Correspondent> {
|
||||
const CorrespondentRepositoryState({
|
||||
super.values = const {},
|
||||
super.hasLoaded,
|
||||
|
||||
@@ -20,6 +20,6 @@ CorrespondentRepositoryState _$CorrespondentRepositoryStateFromJson(
|
||||
Map<String, dynamic> _$CorrespondentRepositoryStateToJson(
|
||||
CorrespondentRepositoryState instance) =>
|
||||
<String, dynamic>{
|
||||
'values': instance.values.map((k, e) => MapEntry(k.toString(), e)),
|
||||
'values': instance.values?.map((k, e) => MapEntry(k.toString(), e)),
|
||||
'hasLoaded': instance.hasLoaded,
|
||||
};
|
||||
|
||||
@@ -1,20 +1,21 @@
|
||||
import 'package:paperless_api/paperless_api.dart';
|
||||
import 'package:paperless_mobile/core/repository/state/repository_state.dart';
|
||||
import 'package:paperless_mobile/core/repository/state/indexed_repository_state.dart';
|
||||
import 'package:json_annotation/json_annotation.dart';
|
||||
|
||||
part 'document_type_repository_state.g.dart';
|
||||
|
||||
@JsonSerializable()
|
||||
class DocumentTypeRepositoryState
|
||||
extends RepositoryState<Map<int, DocumentType>> {
|
||||
class DocumentTypeRepositoryState extends IndexedRepositoryState<DocumentType> {
|
||||
const DocumentTypeRepositoryState({
|
||||
super.values = const {},
|
||||
super.hasLoaded,
|
||||
});
|
||||
|
||||
@override
|
||||
DocumentTypeRepositoryState copyWith(
|
||||
{Map<int, DocumentType>? values, bool? hasLoaded}) {
|
||||
DocumentTypeRepositoryState copyWith({
|
||||
Map<int, DocumentType>? values,
|
||||
bool? hasLoaded,
|
||||
}) {
|
||||
return DocumentTypeRepositoryState(
|
||||
values: values ?? this.values,
|
||||
hasLoaded: hasLoaded ?? this.hasLoaded,
|
||||
|
||||
@@ -20,6 +20,6 @@ DocumentTypeRepositoryState _$DocumentTypeRepositoryStateFromJson(
|
||||
Map<String, dynamic> _$DocumentTypeRepositoryStateToJson(
|
||||
DocumentTypeRepositoryState instance) =>
|
||||
<String, dynamic>{
|
||||
'values': instance.values.map((k, e) => MapEntry(k.toString(), e)),
|
||||
'values': instance.values?.map((k, e) => MapEntry(k.toString(), e)),
|
||||
'hasLoaded': instance.hasLoaded,
|
||||
};
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
import 'package:paperless_api/paperless_api.dart';
|
||||
import 'package:paperless_mobile/core/repository/state/repository_state.dart';
|
||||
import 'package:paperless_mobile/core/repository/state/indexed_repository_state.dart';
|
||||
import 'package:json_annotation/json_annotation.dart';
|
||||
|
||||
part 'saved_view_repository_state.g.dart';
|
||||
|
||||
@JsonSerializable()
|
||||
class SavedViewRepositoryState extends RepositoryState<Map<int, SavedView>> {
|
||||
class SavedViewRepositoryState extends IndexedRepositoryState<SavedView> {
|
||||
const SavedViewRepositoryState({
|
||||
super.values = const {},
|
||||
super.hasLoaded = false,
|
||||
|
||||
@@ -20,6 +20,6 @@ SavedViewRepositoryState _$SavedViewRepositoryStateFromJson(
|
||||
Map<String, dynamic> _$SavedViewRepositoryStateToJson(
|
||||
SavedViewRepositoryState instance) =>
|
||||
<String, dynamic>{
|
||||
'values': instance.values.map((k, e) => MapEntry(k.toString(), e)),
|
||||
'values': instance.values?.map((k, e) => MapEntry(k.toString(), e)),
|
||||
'hasLoaded': instance.hasLoaded,
|
||||
};
|
||||
|
||||
@@ -1,20 +1,21 @@
|
||||
import 'package:paperless_api/paperless_api.dart';
|
||||
import 'package:paperless_mobile/core/repository/state/repository_state.dart';
|
||||
import 'package:paperless_mobile/core/repository/state/indexed_repository_state.dart';
|
||||
import 'package:json_annotation/json_annotation.dart';
|
||||
|
||||
part 'storage_path_repository_state.g.dart';
|
||||
|
||||
@JsonSerializable()
|
||||
class StoragePathRepositoryState
|
||||
extends RepositoryState<Map<int, StoragePath>> {
|
||||
class StoragePathRepositoryState extends IndexedRepositoryState<StoragePath> {
|
||||
const StoragePathRepositoryState({
|
||||
super.values = const {},
|
||||
super.hasLoaded = false,
|
||||
});
|
||||
|
||||
@override
|
||||
StoragePathRepositoryState copyWith(
|
||||
{Map<int, StoragePath>? values, bool? hasLoaded}) {
|
||||
StoragePathRepositoryState copyWith({
|
||||
Map<int, StoragePath>? values,
|
||||
bool? hasLoaded,
|
||||
}) {
|
||||
return StoragePathRepositoryState(
|
||||
values: values ?? this.values,
|
||||
hasLoaded: hasLoaded ?? this.hasLoaded,
|
||||
|
||||
@@ -20,6 +20,6 @@ StoragePathRepositoryState _$StoragePathRepositoryStateFromJson(
|
||||
Map<String, dynamic> _$StoragePathRepositoryStateToJson(
|
||||
StoragePathRepositoryState instance) =>
|
||||
<String, dynamic>{
|
||||
'values': instance.values.map((k, e) => MapEntry(k.toString(), e)),
|
||||
'values': instance.values?.map((k, e) => MapEntry(k.toString(), e)),
|
||||
'hasLoaded': instance.hasLoaded,
|
||||
};
|
||||
|
||||
@@ -1,18 +1,21 @@
|
||||
import 'package:json_annotation/json_annotation.dart';
|
||||
import 'package:paperless_api/paperless_api.dart';
|
||||
import 'package:paperless_mobile/core/repository/state/repository_state.dart';
|
||||
import 'package:paperless_mobile/core/repository/state/indexed_repository_state.dart';
|
||||
|
||||
part 'tag_repository_state.g.dart';
|
||||
|
||||
@JsonSerializable()
|
||||
class TagRepositoryState extends RepositoryState<Map<int, Tag>> {
|
||||
class TagRepositoryState extends IndexedRepositoryState<Tag> {
|
||||
const TagRepositoryState({
|
||||
super.values = const {},
|
||||
super.hasLoaded = false,
|
||||
});
|
||||
|
||||
@override
|
||||
TagRepositoryState copyWith({Map<int, Tag>? values, bool? hasLoaded}) {
|
||||
TagRepositoryState copyWith({
|
||||
Map<int, Tag>? values,
|
||||
bool? hasLoaded,
|
||||
}) {
|
||||
return TagRepositoryState(
|
||||
values: values ?? this.values,
|
||||
hasLoaded: hasLoaded ?? this.hasLoaded,
|
||||
|
||||
@@ -18,6 +18,6 @@ TagRepositoryState _$TagRepositoryStateFromJson(Map<String, dynamic> json) =>
|
||||
|
||||
Map<String, dynamic> _$TagRepositoryStateToJson(TagRepositoryState instance) =>
|
||||
<String, dynamic>{
|
||||
'values': instance.values.map((k, e) => MapEntry(k.toString(), e)),
|
||||
'values': instance.values?.map((k, e) => MapEntry(k.toString(), e)),
|
||||
'hasLoaded': instance.hasLoaded,
|
||||
};
|
||||
|
||||
16
lib/core/repository/state/indexed_repository_state.dart
Normal file
16
lib/core/repository/state/indexed_repository_state.dart
Normal file
@@ -0,0 +1,16 @@
|
||||
abstract class IndexedRepositoryState<T> {
|
||||
final Map<int, T>? values;
|
||||
final bool hasLoaded;
|
||||
|
||||
const IndexedRepositoryState({
|
||||
required this.values,
|
||||
this.hasLoaded = false,
|
||||
}) : assert(!(values == null) || !hasLoaded);
|
||||
|
||||
IndexedRepositoryState.loaded(this.values) : hasLoaded = true;
|
||||
|
||||
IndexedRepositoryState<T> copyWith({
|
||||
Map<int, T>? values,
|
||||
bool? hasLoaded,
|
||||
});
|
||||
}
|
||||
@@ -1,16 +0,0 @@
|
||||
abstract class RepositoryState<T> {
|
||||
final T values;
|
||||
final bool hasLoaded;
|
||||
|
||||
const RepositoryState({
|
||||
required this.values,
|
||||
this.hasLoaded = false,
|
||||
});
|
||||
|
||||
RepositoryState.loaded(this.values) : hasLoaded = true;
|
||||
|
||||
RepositoryState<T> copyWith({
|
||||
T? values,
|
||||
bool? hasLoaded,
|
||||
});
|
||||
}
|
||||
@@ -27,7 +27,7 @@ class GithubIssueService {
|
||||
..tryPutIfAbsent('assignees', () => assignees?.join(','))
|
||||
..tryPutIfAbsent('project', () => project),
|
||||
);
|
||||
log("[GitHubIssueService] Creating GitHub issue: " + uri.toString());
|
||||
debugPrint("[GitHubIssueService] Creating GitHub issue: " + uri.toString());
|
||||
launchUrl(
|
||||
uri,
|
||||
mode: LaunchMode.externalApplication,
|
||||
|
||||
@@ -8,7 +8,7 @@ import 'package:paperless_api/paperless_api.dart';
|
||||
import 'package:paperless_mobile/core/bloc/document_status_cubit.dart';
|
||||
import 'package:paperless_mobile/core/model/document_processing_status.dart';
|
||||
import 'package:paperless_mobile/features/login/model/authentication_information.dart';
|
||||
import 'package:paperless_mobile/util.dart';
|
||||
import 'package:paperless_mobile/constants.dart';
|
||||
import 'package:web_socket_channel/io.dart';
|
||||
|
||||
abstract class StatusService {
|
||||
|
||||
@@ -1,77 +0,0 @@
|
||||
import 'dart:convert';
|
||||
|
||||
import 'package:encrypted_shared_preferences/encrypted_shared_preferences.dart';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:paperless_mobile/core/type/types.dart';
|
||||
import 'package:paperless_mobile/features/login/model/authentication_information.dart';
|
||||
import 'package:paperless_mobile/features/login/model/client_certificate.dart';
|
||||
import 'package:paperless_mobile/features/settings/model/application_settings_state.dart';
|
||||
|
||||
abstract class LocalVault {
|
||||
Future<void> storeAuthenticationInformation(AuthenticationInformation auth);
|
||||
Future<AuthenticationInformation?> loadAuthenticationInformation();
|
||||
Future<ClientCertificate?> loadCertificate();
|
||||
Future<bool> storeApplicationSettings(ApplicationSettingsState settings);
|
||||
Future<ApplicationSettingsState?> loadApplicationSettings();
|
||||
Future<void> clear();
|
||||
}
|
||||
|
||||
class LocalVaultImpl implements LocalVault {
|
||||
static const applicationSettingsKey = "applicationSettings";
|
||||
static const authenticationKey = "authentication";
|
||||
|
||||
final EncryptedSharedPreferences sharedPreferences;
|
||||
|
||||
LocalVaultImpl(this.sharedPreferences);
|
||||
|
||||
@override
|
||||
Future<void> storeAuthenticationInformation(
|
||||
AuthenticationInformation auth,
|
||||
) async {
|
||||
await sharedPreferences.setString(
|
||||
authenticationKey,
|
||||
jsonEncode(auth.toJson()),
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<AuthenticationInformation?> loadAuthenticationInformation() async {
|
||||
if ((await sharedPreferences.getString(authenticationKey)).isEmpty) {
|
||||
return null;
|
||||
}
|
||||
return AuthenticationInformation.fromJson(
|
||||
jsonDecode(await sharedPreferences.getString(authenticationKey)),
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<ClientCertificate?> loadCertificate() async {
|
||||
return loadAuthenticationInformation()
|
||||
.then((value) => value?.clientCertificate);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<bool> storeApplicationSettings(ApplicationSettingsState settings) {
|
||||
return sharedPreferences.setString(
|
||||
applicationSettingsKey,
|
||||
jsonEncode(settings.toJson()),
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<ApplicationSettingsState?> loadApplicationSettings() async {
|
||||
final settings = await sharedPreferences.getString(applicationSettingsKey);
|
||||
if (settings.isEmpty) {
|
||||
return null;
|
||||
}
|
||||
return compute(
|
||||
ApplicationSettingsState.fromJson,
|
||||
jsonDecode(settings) as JSON,
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> clear() {
|
||||
return sharedPreferences.clear();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:paperless_mobile/features/settings/model/color_scheme_option.dart';
|
||||
import 'package:paperless_mobile/generated/l10n.dart';
|
||||
|
||||
String translateColorSchemeOption(
|
||||
BuildContext context, ColorSchemeOption option) {
|
||||
switch (option) {
|
||||
case ColorSchemeOption.classic:
|
||||
return S.of(context).colorSchemeOptionClassic;
|
||||
case ColorSchemeOption.dynamic:
|
||||
return S.of(context).colorSchemeOptionDynamic;
|
||||
}
|
||||
}
|
||||
24
lib/core/translation/sort_field_localization_mapper.dart
Normal file
24
lib/core/translation/sort_field_localization_mapper.dart
Normal file
@@ -0,0 +1,24 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:paperless_api/paperless_api.dart';
|
||||
import 'package:paperless_mobile/generated/l10n.dart';
|
||||
|
||||
String translateSortField(BuildContext context, SortField? sortField) {
|
||||
switch (sortField) {
|
||||
case SortField.archiveSerialNumber:
|
||||
return S.of(context).documentArchiveSerialNumberPropertyShortLabel;
|
||||
case SortField.correspondentName:
|
||||
return S.of(context).documentCorrespondentPropertyLabel;
|
||||
case SortField.title:
|
||||
return S.of(context).documentTitlePropertyLabel;
|
||||
case SortField.documentType:
|
||||
return S.of(context).documentDocumentTypePropertyLabel;
|
||||
case SortField.created:
|
||||
return S.of(context).documentCreatedPropertyLabel;
|
||||
case SortField.added:
|
||||
return S.of(context).documentAddedPropertyLabel;
|
||||
case SortField.modified:
|
||||
return S.of(context).documentModifiedPropertyLabel;
|
||||
default:
|
||||
return '';
|
||||
}
|
||||
}
|
||||
@@ -1,3 +1,6 @@
|
||||
import 'package:paperless_api/paperless_api.dart';
|
||||
import 'package:rxdart/subjects.dart';
|
||||
|
||||
typedef JSON = Map<String, dynamic>;
|
||||
typedef PaperlessValidationErrors = Map<String, String>;
|
||||
typedef PaperlessLocalizedErrorMessage = String;
|
||||
|
||||
217
lib/core/widgets/app_options_popup_menu.dart
Normal file
217
lib/core/widgets/app_options_popup_menu.dart
Normal file
@@ -0,0 +1,217 @@
|
||||
// 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.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).appDrawerReportBugLabel),
|
||||
// ),
|
||||
// );
|
||||
// }
|
||||
|
||||
// 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).appDrawerSettingsLabel),
|
||||
// ),
|
||||
// );
|
||||
// }
|
||||
|
||||
// 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).appDrawerAboutLabel),
|
||||
// ),
|
||||
// );
|
||||
// }
|
||||
|
||||
// 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).aboutDialogDevelopedByText('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;
|
||||
// }
|
||||
// }
|
||||
@@ -1,88 +0,0 @@
|
||||
import 'dart:math';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:paperless_mobile/extensions/flutter_extensions.dart';
|
||||
import 'package:shimmer/shimmer.dart';
|
||||
|
||||
class DocumentsListLoadingWidget extends StatelessWidget {
|
||||
final List<Widget> above;
|
||||
final List<Widget> below;
|
||||
static const tags = [" ", " ", " "];
|
||||
static const titleLengths = <double>[double.infinity, 150.0, 200.0];
|
||||
static const correspondentLengths = <double>[200.0, 300.0, 150.0];
|
||||
static const fontSize = 16.0;
|
||||
|
||||
const DocumentsListLoadingWidget({
|
||||
super.key,
|
||||
this.above = const [],
|
||||
this.below = const [],
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return ListView(
|
||||
children: <Widget>[
|
||||
...above,
|
||||
...List.generate(25, (idx) {
|
||||
final r = Random(idx);
|
||||
final tagCount = r.nextInt(tags.length + 1);
|
||||
final correspondentLength =
|
||||
correspondentLengths[r.nextInt(correspondentLengths.length - 1)];
|
||||
final titleLength = titleLengths[r.nextInt(titleLengths.length - 1)];
|
||||
return Shimmer.fromColors(
|
||||
baseColor: Theme.of(context).brightness == Brightness.light
|
||||
? Colors.grey[300]!
|
||||
: Colors.grey[900]!,
|
||||
highlightColor: Theme.of(context).brightness == Brightness.light
|
||||
? Colors.grey[100]!
|
||||
: Colors.grey[600]!,
|
||||
child: ListTile(
|
||||
contentPadding: const EdgeInsets.all(8),
|
||||
dense: true,
|
||||
isThreeLine: true,
|
||||
leading: ClipRRect(
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
child: Container(
|
||||
color: Colors.white,
|
||||
height: 50,
|
||||
width: 35,
|
||||
),
|
||||
),
|
||||
title: Container(
|
||||
padding: const EdgeInsets.symmetric(vertical: 2.0),
|
||||
width: correspondentLength,
|
||||
height: fontSize,
|
||||
color: Colors.white,
|
||||
),
|
||||
subtitle: Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: 2.0),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
mainAxisAlignment: MainAxisAlignment.spaceAround,
|
||||
children: [
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(vertical: 2.0),
|
||||
height: fontSize,
|
||||
width: titleLength,
|
||||
color: Colors.white,
|
||||
),
|
||||
Wrap(
|
||||
spacing: 2.0,
|
||||
children: List.generate(
|
||||
tagCount,
|
||||
(index) => InputChip(
|
||||
label: Text(tags[r.nextInt(tags.length)]),
|
||||
),
|
||||
),
|
||||
).paddedOnly(top: 4),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}).toList(),
|
||||
...below,
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -6,6 +6,7 @@ import 'package:paperless_mobile/generated/l10n.dart';
|
||||
class HintCard extends StatelessWidget {
|
||||
final String hintText;
|
||||
final double elevation;
|
||||
final IconData hintIcon;
|
||||
final VoidCallback? onHintAcknowledged;
|
||||
final bool show;
|
||||
const HintCard({
|
||||
@@ -13,7 +14,8 @@ class HintCard extends StatelessWidget {
|
||||
required this.hintText,
|
||||
this.onHintAcknowledged,
|
||||
this.elevation = 1,
|
||||
required this.show,
|
||||
this.show = true,
|
||||
this.hintIcon = Icons.tips_and_updates_outlined,
|
||||
});
|
||||
|
||||
@override
|
||||
@@ -31,16 +33,19 @@ class HintCard extends StatelessWidget {
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Icon(
|
||||
Icons.tips_and_updates_outlined,
|
||||
hintIcon,
|
||||
color: Theme.of(context).hintColor,
|
||||
).padded(),
|
||||
Align(
|
||||
alignment: Alignment.center,
|
||||
child: Text(
|
||||
hintText,
|
||||
softWrap: true,
|
||||
textAlign: TextAlign.center,
|
||||
style: Theme.of(context).textTheme.bodySmall,
|
||||
Padding(
|
||||
padding: const EdgeInsets.all(8.0),
|
||||
child: Align(
|
||||
alignment: Alignment.center,
|
||||
child: Text(
|
||||
hintText,
|
||||
softWrap: true,
|
||||
textAlign: TextAlign.center,
|
||||
style: Theme.of(context).textTheme.bodySmall,
|
||||
),
|
||||
),
|
||||
),
|
||||
if (onHintAcknowledged != null)
|
||||
@@ -52,7 +57,7 @@ class HintCard extends StatelessWidget {
|
||||
),
|
||||
)
|
||||
else
|
||||
Padding(padding: EdgeInsets.only(bottom: 24)),
|
||||
const Padding(padding: EdgeInsets.only(bottom: 24)),
|
||||
],
|
||||
).padded(),
|
||||
).padded(),
|
||||
|
||||
602
lib/core/widgets/material/search/m3_search.dart
Normal file
602
lib/core/widgets/material/search/m3_search.dart
Normal file
@@ -0,0 +1,602 @@
|
||||
//TODO: REMOVE THIS WHEN NATIVE MATERIAL FLUTTER SEARCH IS RELEASED
|
||||
// Copyright 2014 The Flutter Authors. All rights reserved.
|
||||
// Use of this source code is governed by a BSD-style license that can be
|
||||
// found in the LICENSE file.
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
|
||||
/// Shows a full screen search page and returns the search result selected by
|
||||
/// the user when the page is closed.
|
||||
///
|
||||
/// The search page consists of an app bar with a search field and a body which
|
||||
/// can either show suggested search queries or the search results.
|
||||
///
|
||||
/// The appearance of the search page is determined by the provided
|
||||
/// `delegate`. The initial query string is given by `query`, which defaults
|
||||
/// to the empty string. When `query` is set to null, `delegate.query` will
|
||||
/// be used as the initial query.
|
||||
///
|
||||
/// This method returns the selected search result, which can be set in the
|
||||
/// [SearchDelegate.close] call. If the search page is closed with the system
|
||||
/// back button, it returns null.
|
||||
///
|
||||
/// A given [SearchDelegate] can only be associated with one active [showMaterial3Search]
|
||||
/// call. Call [SearchDelegate.close] before re-using the same delegate instance
|
||||
/// for another [showMaterial3Search] call.
|
||||
///
|
||||
/// The `useRootNavigator` argument is used to determine whether to push the
|
||||
/// search page to the [Navigator] furthest from or nearest to the given
|
||||
/// `context`. By default, `useRootNavigator` is `false` and the search page
|
||||
/// route created by this method is pushed to the nearest navigator to the
|
||||
/// given `context`. It can not be `null`.
|
||||
///
|
||||
/// The transition to the search page triggered by this method looks best if the
|
||||
/// screen triggering the transition contains an [AppBar] at the top and the
|
||||
/// transition is called from an [IconButton] that's part of [AppBar.actions].
|
||||
/// The animation provided by [SearchDelegate.transitionAnimation] can be used
|
||||
/// to trigger additional animations in the underlying page while the search
|
||||
/// page fades in or out. This is commonly used to animate an [AnimatedIcon] in
|
||||
/// the [AppBar.leading] position e.g. from the hamburger menu to the back arrow
|
||||
/// used to exit the search page.
|
||||
///
|
||||
/// ## Handling emojis and other complex characters
|
||||
/// {@macro flutter.widgets.EditableText.onChanged}
|
||||
///
|
||||
/// See also:
|
||||
///
|
||||
/// * [SearchDelegate] to define the content of the search page.
|
||||
Future<T?> showMaterial3Search<T>({
|
||||
required BuildContext context,
|
||||
required SearchDelegate<T> delegate,
|
||||
String? query = '',
|
||||
bool useRootNavigator = false,
|
||||
}) {
|
||||
delegate.query = query ?? delegate.query;
|
||||
delegate._currentBody = _SearchBody.suggestions;
|
||||
return Navigator.of(context, rootNavigator: useRootNavigator)
|
||||
.push(_SearchPageRoute<T>(
|
||||
delegate: delegate,
|
||||
));
|
||||
}
|
||||
|
||||
/// Delegate for [showMaterial3Search] to define the content of the search page.
|
||||
///
|
||||
/// The search page always shows an [AppBar] at the top where users can
|
||||
/// enter their search queries. The buttons shown before and after the search
|
||||
/// query text field can be customized via [SearchDelegate.buildLeading]
|
||||
/// and [SearchDelegate.buildActions]. Additionally, a widget can be placed
|
||||
/// across the bottom of the [AppBar] via [SearchDelegate.buildBottom].
|
||||
///
|
||||
/// The body below the [AppBar] can either show suggested queries (returned by
|
||||
/// [SearchDelegate.buildSuggestions]) or - once the user submits a search - the
|
||||
/// results of the search as returned by [SearchDelegate.buildResults].
|
||||
///
|
||||
/// [SearchDelegate.query] always contains the current query entered by the user
|
||||
/// and should be used to build the suggestions and results.
|
||||
///
|
||||
/// The results can be brought on screen by calling [SearchDelegate.showResults]
|
||||
/// and you can go back to showing the suggestions by calling
|
||||
/// [SearchDelegate.showSuggestions].
|
||||
///
|
||||
/// Once the user has selected a search result, [SearchDelegate.close] should be
|
||||
/// called to remove the search page from the top of the navigation stack and
|
||||
/// to notify the caller of [showMaterial3Search] about the selected search result.
|
||||
///
|
||||
/// A given [SearchDelegate] can only be associated with one active [showMaterial3Search]
|
||||
/// call. Call [SearchDelegate.close] before re-using the same delegate instance
|
||||
/// for another [showMaterial3Search] call.
|
||||
///
|
||||
/// ## Handling emojis and other complex characters
|
||||
/// {@macro flutter.widgets.EditableText.onChanged}
|
||||
abstract class SearchDelegate<T> {
|
||||
/// Constructor to be called by subclasses which may specify
|
||||
/// [searchFieldLabel], either [searchFieldStyle] or [searchFieldDecorationTheme],
|
||||
/// [keyboardType] and/or [textInputAction]. Only one of [searchFieldLabel]
|
||||
/// and [searchFieldDecorationTheme] may be non-null.
|
||||
///
|
||||
/// {@tool snippet}
|
||||
/// ```dart
|
||||
/// class CustomSearchHintDelegate extends SearchDelegate<String> {
|
||||
/// CustomSearchHintDelegate({
|
||||
/// required String hintText,
|
||||
/// }) : super(
|
||||
/// searchFieldLabel: hintText,
|
||||
/// keyboardType: TextInputType.text,
|
||||
/// textInputAction: TextInputAction.search,
|
||||
/// );
|
||||
///
|
||||
/// @override
|
||||
/// Widget buildLeading(BuildContext context) => const Text('leading');
|
||||
///
|
||||
/// @override
|
||||
/// PreferredSizeWidget buildBottom(BuildContext context) {
|
||||
/// return const PreferredSize(
|
||||
/// preferredSize: Size.fromHeight(56.0),
|
||||
/// child: Text('bottom'));
|
||||
/// }
|
||||
///
|
||||
/// @override
|
||||
/// Widget buildSuggestions(BuildContext context) => const Text('suggestions');
|
||||
///
|
||||
/// @override
|
||||
/// Widget buildResults(BuildContext context) => const Text('results');
|
||||
///
|
||||
/// @override
|
||||
/// List<Widget> buildActions(BuildContext context) => <Widget>[];
|
||||
/// }
|
||||
/// ```
|
||||
/// {@end-tool}
|
||||
SearchDelegate({
|
||||
this.searchFieldLabel,
|
||||
this.searchFieldStyle,
|
||||
this.searchFieldDecorationTheme,
|
||||
this.keyboardType,
|
||||
this.textInputAction = TextInputAction.search,
|
||||
}) : assert(searchFieldStyle == null || searchFieldDecorationTheme == null);
|
||||
|
||||
/// Suggestions shown in the body of the search page while the user types a
|
||||
/// query into the search field.
|
||||
///
|
||||
/// The delegate method is called whenever the content of [query] changes.
|
||||
/// The suggestions should be based on the current [query] string. If the query
|
||||
/// string is empty, it is good practice to show suggested queries based on
|
||||
/// past queries or the current context.
|
||||
///
|
||||
/// Usually, this method will return a [ListView] with one [ListTile] per
|
||||
/// suggestion. When [ListTile.onTap] is called, [query] should be updated
|
||||
/// with the corresponding suggestion and the results page should be shown
|
||||
/// by calling [showResults].
|
||||
Widget buildSuggestions(BuildContext context);
|
||||
|
||||
/// The results shown after the user submits a search from the search page.
|
||||
///
|
||||
/// The current value of [query] can be used to determine what the user
|
||||
/// searched for.
|
||||
///
|
||||
/// This method might be applied more than once to the same query.
|
||||
/// If your [buildResults] method is computationally expensive, you may want
|
||||
/// to cache the search results for one or more queries.
|
||||
///
|
||||
/// Typically, this method returns a [ListView] with the search results.
|
||||
/// When the user taps on a particular search result, [close] should be called
|
||||
/// with the selected result as argument. This will close the search page and
|
||||
/// communicate the result back to the initial caller of [showMaterial3Search].
|
||||
Widget buildResults(BuildContext context);
|
||||
|
||||
/// A widget to display before the current query in the [AppBar].
|
||||
///
|
||||
/// Typically an [IconButton] configured with a [BackButtonIcon] that exits
|
||||
/// the search with [close]. One can also use an [AnimatedIcon] driven by
|
||||
/// [transitionAnimation], which animates from e.g. a hamburger menu to the
|
||||
/// back button as the search overlay fades in.
|
||||
///
|
||||
/// Returns null if no widget should be shown.
|
||||
///
|
||||
/// See also:
|
||||
///
|
||||
/// * [AppBar.leading], the intended use for the return value of this method.
|
||||
Widget? buildLeading(BuildContext context);
|
||||
|
||||
/// Widgets to display after the search query in the [AppBar].
|
||||
///
|
||||
/// If the [query] is not empty, this should typically contain a button to
|
||||
/// clear the query and show the suggestions again (via [showSuggestions]) if
|
||||
/// the results are currently shown.
|
||||
///
|
||||
/// Returns null if no widget should be shown.
|
||||
///
|
||||
/// See also:
|
||||
///
|
||||
/// * [AppBar.actions], the intended use for the return value of this method.
|
||||
List<Widget>? buildActions(BuildContext context);
|
||||
|
||||
/// Widget to display across the bottom of the [AppBar].
|
||||
///
|
||||
/// Returns null by default, i.e. a bottom widget is not included.
|
||||
///
|
||||
/// See also:
|
||||
///
|
||||
/// * [AppBar.bottom], the intended use for the return value of this method.
|
||||
///
|
||||
PreferredSizeWidget? buildBottom(BuildContext context) => null;
|
||||
|
||||
/// The theme used to configure the search page.
|
||||
///
|
||||
/// The returned [ThemeData] will be used to wrap the entire search page,
|
||||
/// so it can be used to configure any of its components with the appropriate
|
||||
/// theme properties.
|
||||
///
|
||||
/// Unless overridden, the default theme will configure the AppBar containing
|
||||
/// the search input text field with a white background and black text on light
|
||||
/// themes. For dark themes the default is a dark grey background with light
|
||||
/// color text.
|
||||
///
|
||||
/// See also:
|
||||
///
|
||||
/// * [AppBarTheme], which configures the AppBar's appearance.
|
||||
/// * [InputDecorationTheme], which configures the appearance of the search
|
||||
/// text field.
|
||||
ThemeData appBarTheme(BuildContext context) {
|
||||
final ThemeData theme = Theme.of(context);
|
||||
final ColorScheme colorScheme = theme.colorScheme;
|
||||
return theme.copyWith(
|
||||
appBarTheme: AppBarTheme(
|
||||
systemOverlayStyle: colorScheme.brightness == Brightness.light
|
||||
? SystemUiOverlayStyle.light
|
||||
: SystemUiOverlayStyle.dark,
|
||||
backgroundColor: colorScheme.brightness == Brightness.dark
|
||||
? Colors.grey[900]
|
||||
: Colors.white,
|
||||
iconTheme: theme.primaryIconTheme.copyWith(color: Colors.grey),
|
||||
),
|
||||
inputDecorationTheme: searchFieldDecorationTheme ??
|
||||
InputDecorationTheme(
|
||||
hintStyle: searchFieldStyle ?? theme.inputDecorationTheme.hintStyle,
|
||||
border: InputBorder.none,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// The current query string shown in the [AppBar].
|
||||
///
|
||||
/// The user manipulates this string via the keyboard.
|
||||
///
|
||||
/// If the user taps on a suggestion provided by [buildSuggestions] this
|
||||
/// string should be updated to that suggestion via the setter.
|
||||
String get query => _queryTextController.text;
|
||||
|
||||
/// Changes the current query string.
|
||||
///
|
||||
/// Setting the query string programmatically moves the cursor to the end of the text field.
|
||||
set query(String value) {
|
||||
assert(query != null);
|
||||
_queryTextController.text = value;
|
||||
if (_queryTextController.text.isNotEmpty) {
|
||||
_queryTextController.selection = TextSelection.fromPosition(
|
||||
TextPosition(offset: _queryTextController.text.length));
|
||||
}
|
||||
}
|
||||
|
||||
/// Transition from the suggestions returned by [buildSuggestions] to the
|
||||
/// [query] results returned by [buildResults].
|
||||
///
|
||||
/// If the user taps on a suggestion provided by [buildSuggestions] the
|
||||
/// screen should typically transition to the page showing the search
|
||||
/// results for the suggested query. This transition can be triggered
|
||||
/// by calling this method.
|
||||
///
|
||||
/// See also:
|
||||
///
|
||||
/// * [showSuggestions] to show the search suggestions again.
|
||||
void showResults(BuildContext context) {
|
||||
_focusNode?.unfocus();
|
||||
_currentBody = _SearchBody.results;
|
||||
}
|
||||
|
||||
/// Transition from showing the results returned by [buildResults] to showing
|
||||
/// the suggestions returned by [buildSuggestions].
|
||||
///
|
||||
/// Calling this method will also put the input focus back into the search
|
||||
/// field of the [AppBar].
|
||||
///
|
||||
/// If the results are currently shown this method can be used to go back
|
||||
/// to showing the search suggestions.
|
||||
///
|
||||
/// See also:
|
||||
///
|
||||
/// * [showResults] to show the search results.
|
||||
void showSuggestions(BuildContext context) {
|
||||
assert(_focusNode != null,
|
||||
'_focusNode must be set by route before showSuggestions is called.');
|
||||
_focusNode!.requestFocus();
|
||||
_currentBody = _SearchBody.suggestions;
|
||||
}
|
||||
|
||||
/// Closes the search page and returns to the underlying route.
|
||||
///
|
||||
/// The value provided for `result` is used as the return value of the call
|
||||
/// to [showMaterial3Search] that launched the search initially.
|
||||
void close(BuildContext context, T result) {
|
||||
_currentBody = null;
|
||||
_focusNode?.unfocus();
|
||||
Navigator.of(context)
|
||||
..popUntil((Route<dynamic> route) => route == _route)
|
||||
..pop(result);
|
||||
}
|
||||
|
||||
/// The hint text that is shown in the search field when it is empty.
|
||||
///
|
||||
/// If this value is set to null, the value of
|
||||
/// `MaterialLocalizations.of(context).searchFieldLabel` will be used instead.
|
||||
final String? searchFieldLabel;
|
||||
|
||||
/// The style of the [searchFieldLabel].
|
||||
///
|
||||
/// If this value is set to null, the value of the ambient [Theme]'s
|
||||
/// [InputDecorationTheme.hintStyle] will be used instead.
|
||||
///
|
||||
/// Only one of [searchFieldStyle] or [searchFieldDecorationTheme] can
|
||||
/// be non-null.
|
||||
final TextStyle? searchFieldStyle;
|
||||
|
||||
/// The [InputDecorationTheme] used to configure the search field's visuals.
|
||||
///
|
||||
/// Only one of [searchFieldStyle] or [searchFieldDecorationTheme] can
|
||||
/// be non-null.
|
||||
final InputDecorationTheme? searchFieldDecorationTheme;
|
||||
|
||||
/// The type of action button to use for the keyboard.
|
||||
///
|
||||
/// Defaults to the default value specified in [TextField].
|
||||
final TextInputType? keyboardType;
|
||||
|
||||
/// The text input action configuring the soft keyboard to a particular action
|
||||
/// button.
|
||||
///
|
||||
/// Defaults to [TextInputAction.search].
|
||||
final TextInputAction textInputAction;
|
||||
|
||||
/// [Animation] triggered when the search pages fades in or out.
|
||||
///
|
||||
/// This animation is commonly used to animate [AnimatedIcon]s of
|
||||
/// [IconButton]s returned by [buildLeading] or [buildActions]. It can also be
|
||||
/// used to animate [IconButton]s contained within the route below the search
|
||||
/// page.
|
||||
Animation<double> get transitionAnimation => _proxyAnimation;
|
||||
|
||||
// The focus node to use for manipulating focus on the search page. This is
|
||||
// managed, owned, and set by the _SearchPageRoute using this delegate.
|
||||
FocusNode? _focusNode;
|
||||
|
||||
final TextEditingController _queryTextController = TextEditingController();
|
||||
|
||||
final ProxyAnimation _proxyAnimation =
|
||||
ProxyAnimation(kAlwaysDismissedAnimation);
|
||||
|
||||
final ValueNotifier<_SearchBody?> _currentBodyNotifier =
|
||||
ValueNotifier<_SearchBody?>(null);
|
||||
|
||||
_SearchBody? get _currentBody => _currentBodyNotifier.value;
|
||||
set _currentBody(_SearchBody? value) {
|
||||
_currentBodyNotifier.value = value;
|
||||
}
|
||||
|
||||
_SearchPageRoute<T>? _route;
|
||||
}
|
||||
|
||||
/// Describes the body that is currently shown under the [AppBar] in the
|
||||
/// search page.
|
||||
enum _SearchBody {
|
||||
/// Suggested queries are shown in the body.
|
||||
///
|
||||
/// The suggested queries are generated by [SearchDelegate.buildSuggestions].
|
||||
suggestions,
|
||||
|
||||
/// Search results are currently shown in the body.
|
||||
///
|
||||
/// The search results are generated by [SearchDelegate.buildResults].
|
||||
results,
|
||||
}
|
||||
|
||||
class _SearchPageRoute<T> extends PageRoute<T> {
|
||||
_SearchPageRoute({
|
||||
required this.delegate,
|
||||
}) {
|
||||
assert(
|
||||
delegate._route == null,
|
||||
'The ${delegate.runtimeType} instance is currently used by another active '
|
||||
'search. Please close that search by calling close() on the SearchDelegate '
|
||||
'before opening another search with the same delegate instance.',
|
||||
);
|
||||
delegate._route = this;
|
||||
}
|
||||
|
||||
final SearchDelegate<T> delegate;
|
||||
|
||||
@override
|
||||
Color? get barrierColor => null;
|
||||
|
||||
@override
|
||||
String? get barrierLabel => null;
|
||||
|
||||
@override
|
||||
Duration get transitionDuration => const Duration(milliseconds: 300);
|
||||
|
||||
@override
|
||||
bool get maintainState => false;
|
||||
|
||||
@override
|
||||
Widget buildTransitions(
|
||||
BuildContext context,
|
||||
Animation<double> animation,
|
||||
Animation<double> secondaryAnimation,
|
||||
Widget child,
|
||||
) {
|
||||
return FadeTransition(
|
||||
opacity: animation,
|
||||
child: child,
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Animation<double> createAnimation() {
|
||||
final Animation<double> animation = super.createAnimation();
|
||||
delegate._proxyAnimation.parent = animation;
|
||||
return animation;
|
||||
}
|
||||
|
||||
@override
|
||||
Widget buildPage(
|
||||
BuildContext context,
|
||||
Animation<double> animation,
|
||||
Animation<double> secondaryAnimation,
|
||||
) {
|
||||
return _SearchPage<T>(
|
||||
delegate: delegate,
|
||||
animation: animation,
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
void didComplete(T? result) {
|
||||
super.didComplete(result);
|
||||
assert(delegate._route == this);
|
||||
delegate._route = null;
|
||||
delegate._currentBody = null;
|
||||
}
|
||||
}
|
||||
|
||||
class _SearchPage<T> extends StatefulWidget {
|
||||
const _SearchPage({
|
||||
required this.delegate,
|
||||
required this.animation,
|
||||
});
|
||||
|
||||
final SearchDelegate<T> delegate;
|
||||
final Animation<double> animation;
|
||||
|
||||
@override
|
||||
State<StatefulWidget> createState() => _SearchPageState<T>();
|
||||
}
|
||||
|
||||
class _SearchPageState<T> extends State<_SearchPage<T>> {
|
||||
// This node is owned, but not hosted by, the search page. Hosting is done by
|
||||
// the text field.
|
||||
FocusNode focusNode = FocusNode();
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
widget.delegate._queryTextController.addListener(_onQueryChanged);
|
||||
widget.animation.addStatusListener(_onAnimationStatusChanged);
|
||||
widget.delegate._currentBodyNotifier.addListener(_onSearchBodyChanged);
|
||||
focusNode.addListener(_onFocusChanged);
|
||||
widget.delegate._focusNode = focusNode;
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
super.dispose();
|
||||
widget.delegate._queryTextController.removeListener(_onQueryChanged);
|
||||
widget.animation.removeStatusListener(_onAnimationStatusChanged);
|
||||
widget.delegate._currentBodyNotifier.removeListener(_onSearchBodyChanged);
|
||||
widget.delegate._focusNode = null;
|
||||
focusNode.dispose();
|
||||
}
|
||||
|
||||
void _onAnimationStatusChanged(AnimationStatus status) {
|
||||
if (status != AnimationStatus.completed) {
|
||||
return;
|
||||
}
|
||||
widget.animation.removeStatusListener(_onAnimationStatusChanged);
|
||||
if (widget.delegate._currentBody == _SearchBody.suggestions) {
|
||||
focusNode.requestFocus();
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
void didUpdateWidget(_SearchPage<T> oldWidget) {
|
||||
super.didUpdateWidget(oldWidget);
|
||||
if (widget.delegate != oldWidget.delegate) {
|
||||
oldWidget.delegate._queryTextController.removeListener(_onQueryChanged);
|
||||
widget.delegate._queryTextController.addListener(_onQueryChanged);
|
||||
oldWidget.delegate._currentBodyNotifier
|
||||
.removeListener(_onSearchBodyChanged);
|
||||
widget.delegate._currentBodyNotifier.addListener(_onSearchBodyChanged);
|
||||
oldWidget.delegate._focusNode = null;
|
||||
widget.delegate._focusNode = focusNode;
|
||||
}
|
||||
}
|
||||
|
||||
void _onFocusChanged() {
|
||||
if (focusNode.hasFocus &&
|
||||
widget.delegate._currentBody != _SearchBody.suggestions) {
|
||||
widget.delegate.showSuggestions(context);
|
||||
}
|
||||
}
|
||||
|
||||
void _onQueryChanged() {
|
||||
setState(() {
|
||||
// rebuild ourselves because query changed.
|
||||
});
|
||||
}
|
||||
|
||||
void _onSearchBodyChanged() {
|
||||
setState(() {
|
||||
// rebuild ourselves because search body changed.
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
assert(debugCheckHasMaterialLocalizations(context));
|
||||
final ThemeData theme = widget.delegate.appBarTheme(context);
|
||||
final String searchFieldLabel = widget.delegate.searchFieldLabel ??
|
||||
MaterialLocalizations.of(context).searchFieldLabel;
|
||||
Widget? body;
|
||||
switch (widget.delegate._currentBody) {
|
||||
case _SearchBody.suggestions:
|
||||
body = KeyedSubtree(
|
||||
key: const ValueKey<_SearchBody>(_SearchBody.suggestions),
|
||||
child: widget.delegate.buildSuggestions(context),
|
||||
);
|
||||
break;
|
||||
case _SearchBody.results:
|
||||
body = KeyedSubtree(
|
||||
key: const ValueKey<_SearchBody>(_SearchBody.results),
|
||||
child: widget.delegate.buildResults(context),
|
||||
);
|
||||
break;
|
||||
case null:
|
||||
break;
|
||||
}
|
||||
|
||||
late final String routeName;
|
||||
switch (theme.platform) {
|
||||
case TargetPlatform.iOS:
|
||||
case TargetPlatform.macOS:
|
||||
routeName = '';
|
||||
break;
|
||||
case TargetPlatform.android:
|
||||
case TargetPlatform.fuchsia:
|
||||
case TargetPlatform.linux:
|
||||
case TargetPlatform.windows:
|
||||
routeName = searchFieldLabel;
|
||||
}
|
||||
|
||||
return Semantics(
|
||||
explicitChildNodes: true,
|
||||
scopesRoute: true,
|
||||
namesRoute: true,
|
||||
label: routeName,
|
||||
child: Theme(
|
||||
data: theme,
|
||||
child: Scaffold(
|
||||
appBar: AppBar(
|
||||
toolbarHeight: 72,
|
||||
leading: widget.delegate.buildLeading(context),
|
||||
title: TextField(
|
||||
controller: widget.delegate._queryTextController,
|
||||
focusNode: focusNode,
|
||||
style: widget.delegate.searchFieldStyle ??
|
||||
theme.textTheme.titleLarge,
|
||||
textInputAction: widget.delegate.textInputAction,
|
||||
keyboardType: widget.delegate.keyboardType,
|
||||
onSubmitted: (String _) {
|
||||
widget.delegate.showResults(context);
|
||||
},
|
||||
decoration: InputDecoration(hintText: searchFieldLabel),
|
||||
),
|
||||
actions: widget.delegate.buildActions(context),
|
||||
bottom: widget.delegate.buildBottom(context),
|
||||
),
|
||||
body: AnimatedSwitcher(
|
||||
duration: const Duration(milliseconds: 300),
|
||||
child: body,
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
79
lib/core/widgets/material/search/m3_search_bar.dart
Normal file
79
lib/core/widgets/material/search/m3_search_bar.dart
Normal file
@@ -0,0 +1,79 @@
|
||||
import 'dart:math';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
class SearchBar extends StatelessWidget {
|
||||
const SearchBar({
|
||||
Key? key,
|
||||
this.height = 56,
|
||||
required this.leadingIcon,
|
||||
this.trailingIcon,
|
||||
required this.supportingText,
|
||||
required this.onTap,
|
||||
}) : super(key: key);
|
||||
|
||||
final double height;
|
||||
double get effectiveHeight {
|
||||
return max(height, 48);
|
||||
}
|
||||
|
||||
final VoidCallback onTap;
|
||||
final Widget leadingIcon;
|
||||
final Widget? trailingIcon;
|
||||
|
||||
final String supportingText;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final ColorScheme colorScheme = Theme.of(context).colorScheme;
|
||||
final TextTheme textTheme = Theme.of(context).textTheme;
|
||||
|
||||
return Container(
|
||||
constraints: const BoxConstraints(minWidth: 360, maxWidth: 720),
|
||||
width: double.infinity,
|
||||
height: effectiveHeight,
|
||||
child: Material(
|
||||
elevation: 1,
|
||||
color: colorScheme.surface,
|
||||
shadowColor: colorScheme.shadow,
|
||||
surfaceTintColor: colorScheme.surfaceTint,
|
||||
borderRadius: BorderRadius.circular(effectiveHeight / 2),
|
||||
child: InkWell(
|
||||
onTap: onTap,
|
||||
borderRadius: BorderRadius.circular(effectiveHeight / 2),
|
||||
highlightColor: Colors.transparent,
|
||||
splashFactory: InkRipple.splashFactory,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8),
|
||||
child: Row(children: [
|
||||
leadingIcon,
|
||||
Expanded(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.only(right: 8),
|
||||
child: TextField(
|
||||
onTap: onTap,
|
||||
readOnly: true,
|
||||
enabled: false,
|
||||
cursorColor: colorScheme.primary,
|
||||
style: textTheme.bodyLarge,
|
||||
textAlignVertical: TextAlignVertical.center,
|
||||
decoration: InputDecoration(
|
||||
isCollapsed: true,
|
||||
border: InputBorder.none,
|
||||
contentPadding: const EdgeInsets.symmetric(horizontal: 8),
|
||||
hintText: supportingText,
|
||||
hintStyle: textTheme.bodyLarge?.apply(
|
||||
color: colorScheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
if (trailingIcon != null) trailingIcon!,
|
||||
]),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -2,18 +2,25 @@ import 'package:flutter/material.dart';
|
||||
import 'package:flutter_svg/flutter_svg.dart';
|
||||
|
||||
class PaperlessLogo extends StatelessWidget {
|
||||
static const _paperlessGreen = Color(0xFF18541F);
|
||||
final double? height;
|
||||
final double? width;
|
||||
final String _path;
|
||||
final Color _color;
|
||||
|
||||
const PaperlessLogo.white({super.key, this.height, this.width})
|
||||
: _path = "assets/logos/paperless_logo_white.svg";
|
||||
const PaperlessLogo.white({
|
||||
super.key,
|
||||
this.height,
|
||||
this.width,
|
||||
}) : _color = Colors.white;
|
||||
|
||||
const PaperlessLogo.green({super.key, this.height, this.width})
|
||||
: _path = "assets/logos/paperless_logo_green.svg";
|
||||
: _color = _paperlessGreen;
|
||||
|
||||
const PaperlessLogo.black({super.key, this.height, this.width})
|
||||
: _path = "assets/logos/paperless_logo_black.svg";
|
||||
: _color = Colors.black;
|
||||
|
||||
const PaperlessLogo.colored(Color color, {super.key, this.height, this.width})
|
||||
: _color = color;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
@@ -24,7 +31,8 @@ class PaperlessLogo extends StatelessWidget {
|
||||
),
|
||||
padding: const EdgeInsets.only(right: 8),
|
||||
child: SvgPicture.asset(
|
||||
_path,
|
||||
"assets/logos/paperless_logo_white.svg",
|
||||
color: _color,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
21
lib/core/widgets/shimmer_placeholder.dart
Normal file
21
lib/core/widgets/shimmer_placeholder.dart
Normal file
@@ -0,0 +1,21 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:shimmer/shimmer.dart';
|
||||
|
||||
class ShimmerPlaceholder extends StatelessWidget {
|
||||
final Widget child;
|
||||
|
||||
const ShimmerPlaceholder({super.key, required this.child});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Shimmer.fromColors(
|
||||
baseColor: Theme.of(context).brightness == Brightness.light
|
||||
? Colors.grey[300]!
|
||||
: Colors.grey[900]!,
|
||||
highlightColor: Theme.of(context).brightness == Brightness.light
|
||||
? Colors.grey[100]!
|
||||
: Colors.grey[600]!,
|
||||
child: child,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,5 +0,0 @@
|
||||
extension DateComparisons on DateTime {
|
||||
bool isEqualToIgnoringDate(DateTime other) {
|
||||
return day == other.day && month == other.month && year == other.year;
|
||||
}
|
||||
}
|
||||
@@ -1,7 +1,6 @@
|
||||
import 'package:hydrated_bloc/hydrated_bloc.dart';
|
||||
import 'package:paperless_mobile/features/login/bloc/authentication_state.dart';
|
||||
import 'package:paperless_mobile/features/settings/bloc/application_settings_cubit.dart';
|
||||
import 'package:paperless_mobile/features/settings/model/application_settings_state.dart';
|
||||
import 'package:paperless_mobile/features/settings/bloc/application_settings_state.dart';
|
||||
|
||||
extension AddressableHydratedStorage on Storage {
|
||||
ApplicationSettingsState get settings {
|
||||
|
||||
@@ -1,22 +0,0 @@
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:paperless_mobile/features/login/model/client_certificate.dart';
|
||||
|
||||
extension ClientCertificateHandlingSecurityContext on SecurityContext {
|
||||
SecurityContext withClientCertificate(ClientCertificate? clientCertificate) {
|
||||
if (clientCertificate == null) return this;
|
||||
return this
|
||||
..usePrivateKeyBytes(
|
||||
clientCertificate.bytes,
|
||||
password: clientCertificate.passphrase,
|
||||
)
|
||||
..useCertificateChainBytes(
|
||||
clientCertificate.bytes,
|
||||
password: clientCertificate.passphrase,
|
||||
)
|
||||
..setTrustedCertificatesBytes(
|
||||
clientCertificate.bytes,
|
||||
password: clientCertificate.passphrase,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,7 +0,0 @@
|
||||
extension SizeLimitedString on String {
|
||||
String withLengthLimitedTo(int length, [String overflow = "..."]) {
|
||||
return this.length > length
|
||||
? '${substring(0, length - overflow.length)}$overflow'
|
||||
: this;
|
||||
}
|
||||
}
|
||||
117
lib/features/app_drawer/view/app_drawer.dart
Normal file
117
lib/features/app_drawer/view/app_drawer.dart
Normal file
@@ -0,0 +1,117 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_bloc/flutter_bloc.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/settings/bloc/application_settings_cubit.dart';
|
||||
import 'package:paperless_mobile/features/settings/view/settings_page.dart';
|
||||
import 'package:paperless_mobile/generated/l10n.dart';
|
||||
import 'package:url_launcher/link.dart';
|
||||
import 'package:url_launcher/url_launcher_string.dart';
|
||||
|
||||
class AppDrawer extends StatelessWidget {
|
||||
const AppDrawer({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return SafeArea(
|
||||
top: true,
|
||||
child: Drawer(
|
||||
child: Column(
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
const PaperlessLogo.green(),
|
||||
Text(
|
||||
"Paperless Mobile",
|
||||
style: Theme.of(context).textTheme.titleMedium,
|
||||
),
|
||||
],
|
||||
).padded(),
|
||||
const Divider(),
|
||||
ListTile(
|
||||
dense: true,
|
||||
title: Text(S.of(context).appDrawerAboutLabel),
|
||||
leading: const Icon(Icons.info_outline),
|
||||
onTap: () => _showAboutDialog(context),
|
||||
),
|
||||
ListTile(
|
||||
dense: true,
|
||||
leading: const Icon(Icons.bug_report_outlined),
|
||||
title: Text(S.of(context).appDrawerReportBugLabel),
|
||||
onTap: () {
|
||||
launchUrlString(
|
||||
'https://github.com/astubenbord/paperless-mobile/issues/new');
|
||||
},
|
||||
),
|
||||
ListTile(
|
||||
dense: true,
|
||||
leading: const Icon(Icons.settings_outlined),
|
||||
title: Text(
|
||||
S.of(context).appDrawerSettingsLabel,
|
||||
),
|
||||
onTap: () => Navigator.of(context).push(
|
||||
MaterialPageRoute(
|
||||
builder: (context) => BlocProvider.value(
|
||||
value: context.read<ApplicationSettingsCubit>(),
|
||||
child: const SettingsPage(),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
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).aboutDialogDevelopedByText('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.')
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,27 +0,0 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
class WelcomeIntroSlide extends StatelessWidget {
|
||||
const WelcomeIntroSlide({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Column(
|
||||
children: [
|
||||
Text(
|
||||
"Welcome to Paperless Mobile!",
|
||||
textAlign: TextAlign.center,
|
||||
style: Theme.of(context).textTheme.titleLarge,
|
||||
),
|
||||
Padding(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: Text(
|
||||
"Manage, share and create documents on the go without any compromises!",
|
||||
textAlign: TextAlign.center,
|
||||
style: TextStyle(color: Theme.of(context).hintColor),
|
||||
),
|
||||
),
|
||||
Align(child: Image.asset("assets/logos/paperless_logo_green.png")),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,26 +1,32 @@
|
||||
import 'dart:developer';
|
||||
import 'dart:async';
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:bloc/bloc.dart';
|
||||
import 'package:equatable/equatable.dart';
|
||||
import 'package:paperless_api/paperless_api.dart';
|
||||
import 'package:paperless_mobile/core/service/file_service.dart';
|
||||
import 'package:url_launcher/url_launcher.dart';
|
||||
import 'package:url_launcher/url_launcher_string.dart';
|
||||
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/service/file_service.dart';
|
||||
|
||||
part 'document_details_state.dart';
|
||||
|
||||
class DocumentDetailsCubit extends Cubit<DocumentDetailsState> {
|
||||
final PaperlessDocumentsApi _api;
|
||||
final DocumentChangedNotifier _notifier;
|
||||
|
||||
DocumentDetailsCubit(this._api, DocumentModel initialDocument)
|
||||
: super(DocumentDetailsState(document: initialDocument)) {
|
||||
final List<StreamSubscription> _subscriptions = [];
|
||||
DocumentDetailsCubit(
|
||||
this._api,
|
||||
this._notifier, {
|
||||
required DocumentModel initialDocument,
|
||||
}) : super(DocumentDetailsState(document: initialDocument)) {
|
||||
_notifier.subscribe(this, onUpdated: replace);
|
||||
loadSuggestions();
|
||||
}
|
||||
|
||||
Future<void> delete(DocumentModel document) async {
|
||||
await _api.delete(document);
|
||||
_notifier.notifyDeleted(document);
|
||||
}
|
||||
|
||||
Future<void> loadSuggestions() async {
|
||||
@@ -44,21 +50,35 @@ class DocumentDetailsCubit extends Cubit<DocumentDetailsState> {
|
||||
final int asn = await _api.findNextAsn();
|
||||
final updatedDocument =
|
||||
await _api.update(document.copyWith(archiveSerialNumber: asn));
|
||||
emit(state.copyWith(document: updatedDocument));
|
||||
_notifier.notifyUpdated(updatedDocument);
|
||||
}
|
||||
}
|
||||
|
||||
Future<ResultType> openDocumentInSystemViewer() async {
|
||||
final downloadDir = await FileService.temporaryDirectory;
|
||||
final cacheDir = await FileService.temporaryDirectory;
|
||||
|
||||
final metaData = await _api.getMetaData(state.document);
|
||||
final docBytes = await _api.download(state.document);
|
||||
File f = File('${downloadDir.path}/${metaData.mediaFilename}');
|
||||
f.writeAsBytes(docBytes);
|
||||
return OpenFilex.open(f.path, type: "application/pdf")
|
||||
.then((value) => value.type);
|
||||
final bytes = await _api.download(state.document);
|
||||
|
||||
final file = File('${cacheDir.path}/${metaData.mediaFilename}')
|
||||
..createSync(recursive: true)
|
||||
..writeAsBytesSync(bytes);
|
||||
|
||||
return OpenFilex.open(file.path, type: "application/pdf").then(
|
||||
(value) => value.type,
|
||||
);
|
||||
}
|
||||
|
||||
void replaceDocument(DocumentModel document) {
|
||||
void replace(DocumentModel document) {
|
||||
emit(state.copyWith(document: document));
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> close() {
|
||||
for (final element in _subscriptions) {
|
||||
element.cancel();
|
||||
}
|
||||
_notifier.unsubscribe(this);
|
||||
return super.close();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import 'dart:io';
|
||||
import 'dart:math';
|
||||
|
||||
import 'package:badges/badges.dart' as b;
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
@@ -8,7 +8,6 @@ import 'package:intl/intl.dart';
|
||||
import 'package:open_filex/open_filex.dart';
|
||||
import 'package:paperless_api/paperless_api.dart';
|
||||
import 'package:paperless_mobile/core/bloc/connectivity_cubit.dart';
|
||||
import 'package:paperless_mobile/core/repository/state/impl/correspondent_repository_state.dart';
|
||||
import 'package:paperless_mobile/core/translation/error_code_localization_mapper.dart';
|
||||
import 'package:paperless_mobile/core/widgets/highlighted_text.dart';
|
||||
import 'package:paperless_mobile/core/widgets/offline_widget.dart';
|
||||
@@ -23,14 +22,15 @@ import 'package:paperless_mobile/features/edit_document/cubit/edit_document_cubi
|
||||
import 'package:paperless_mobile/features/labels/storage_path/view/widgets/storage_path_widget.dart';
|
||||
import 'package:paperless_mobile/features/labels/tags/view/widgets/tags_widget.dart';
|
||||
import 'package:paperless_mobile/features/labels/view/widgets/label_text.dart';
|
||||
import 'package:paperless_mobile/features/similar_documents/cubit/similar_documents_cubit.dart';
|
||||
import 'package:paperless_mobile/generated/l10n.dart';
|
||||
import 'package:paperless_mobile/util.dart';
|
||||
import 'package:paperless_mobile/helpers/format_helpers.dart';
|
||||
import 'package:paperless_mobile/helpers/message_helpers.dart';
|
||||
import 'package:path_provider/path_provider.dart';
|
||||
import 'package:share_plus/share_plus.dart';
|
||||
import 'package:badges/badges.dart' as b;
|
||||
|
||||
import '../../../../core/repository/state/impl/document_type_repository_state.dart';
|
||||
import 'package:paperless_mobile/features/similar_documents/view/similar_documents_view.dart';
|
||||
|
||||
//TODO: Refactor this into several widgets
|
||||
class DocumentDetailsPage extends StatefulWidget {
|
||||
final bool allowEdit;
|
||||
final bool isLabelClickable;
|
||||
@@ -48,6 +48,16 @@ class DocumentDetailsPage extends StatefulWidget {
|
||||
}
|
||||
|
||||
class _DocumentDetailsPageState extends State<DocumentDetailsPage> {
|
||||
late Future<DocumentMetaData> _metaData;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_metaData = context
|
||||
.read<PaperlessDocumentsApi>()
|
||||
.getMetaData(context.read<DocumentDetailsCubit>().state.document);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return WillPopScope(
|
||||
@@ -57,115 +67,15 @@ class _DocumentDetailsPageState extends State<DocumentDetailsPage> {
|
||||
return false;
|
||||
},
|
||||
child: DefaultTabController(
|
||||
length: 3,
|
||||
length: 4,
|
||||
child: Scaffold(
|
||||
floatingActionButtonLocation: FloatingActionButtonLocation.endDocked,
|
||||
floatingActionButton: widget.allowEdit
|
||||
? BlocBuilder<DocumentDetailsCubit, DocumentDetailsState>(
|
||||
builder: (context, state) {
|
||||
final _filteredSuggestions =
|
||||
state.suggestions.documentDifference(state.document);
|
||||
return BlocBuilder<ConnectivityCubit, ConnectivityState>(
|
||||
builder: (context, connectivityState) {
|
||||
if (!connectivityState.isConnected) {
|
||||
return Container();
|
||||
}
|
||||
return b.Badge(
|
||||
position: b.BadgePosition.topEnd(top: -12, end: -6),
|
||||
showBadge: _filteredSuggestions.hasSuggestions,
|
||||
child: Tooltip(
|
||||
message:
|
||||
S.of(context).documentDetailsPageEditTooltip,
|
||||
preferBelow: false,
|
||||
verticalOffset: 40,
|
||||
child: FloatingActionButton(
|
||||
child: const Icon(Icons.edit),
|
||||
onPressed: () => _onEdit(state.document),
|
||||
),
|
||||
),
|
||||
badgeContent: Text(
|
||||
'${_filteredSuggestions.suggestionsCount}',
|
||||
style: const TextStyle(
|
||||
color: Colors.white,
|
||||
),
|
||||
),
|
||||
badgeColor: Colors.red,
|
||||
);
|
||||
},
|
||||
);
|
||||
},
|
||||
)
|
||||
: null,
|
||||
bottomNavigationBar:
|
||||
BlocBuilder<DocumentDetailsCubit, DocumentDetailsState>(
|
||||
builder: (context, state) {
|
||||
return BottomAppBar(
|
||||
child: BlocBuilder<ConnectivityCubit, ConnectivityState>(
|
||||
builder: (context, connectivityState) {
|
||||
final isConnected = connectivityState.isConnected;
|
||||
return Row(
|
||||
mainAxisAlignment: MainAxisAlignment.start,
|
||||
children: [
|
||||
IconButton(
|
||||
tooltip:
|
||||
S.of(context).documentDetailsPageDeleteTooltip,
|
||||
icon: const Icon(Icons.delete),
|
||||
onPressed: widget.allowEdit && isConnected
|
||||
? () => _onDelete(state.document)
|
||||
: null,
|
||||
).paddedSymmetrically(horizontal: 4),
|
||||
Tooltip(
|
||||
message:
|
||||
S.of(context).documentDetailsPageDownloadTooltip,
|
||||
child: DocumentDownloadButton(
|
||||
document: state.document,
|
||||
enabled: isConnected,
|
||||
),
|
||||
),
|
||||
IconButton(
|
||||
tooltip:
|
||||
S.of(context).documentDetailsPagePreviewTooltip,
|
||||
icon: const Icon(Icons.visibility),
|
||||
onPressed: isConnected
|
||||
? () => _onOpen(state.document)
|
||||
: null,
|
||||
).paddedOnly(right: 4.0),
|
||||
IconButton(
|
||||
tooltip: S
|
||||
.of(context)
|
||||
.documentDetailsPageOpenInSystemViewerTooltip,
|
||||
icon: const Icon(Icons.open_in_new),
|
||||
onPressed:
|
||||
isConnected ? _onOpenFileInSystemViewer : null,
|
||||
).paddedOnly(right: 4.0),
|
||||
IconButton(
|
||||
tooltip:
|
||||
S.of(context).documentDetailsPageShareTooltip,
|
||||
icon: const Icon(Icons.share),
|
||||
onPressed: isConnected
|
||||
? () => _onShare(state.document)
|
||||
: null,
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
floatingActionButton: widget.allowEdit ? _buildAppBar() : null,
|
||||
bottomNavigationBar: _buildBottomAppBar(),
|
||||
body: NestedScrollView(
|
||||
headerSliverBuilder: (context, innerBoxIsScrolled) => [
|
||||
SliverAppBar(
|
||||
leading: IconButton(
|
||||
icon: const Icon(
|
||||
Icons.arrow_back,
|
||||
color: Colors
|
||||
.black, //TODO: check if there is a way to dynamically determine color...
|
||||
),
|
||||
onPressed: () => Navigator.of(context).pop(
|
||||
context.read<DocumentDetailsCubit>().state.document,
|
||||
),
|
||||
),
|
||||
leading: const BackButton(),
|
||||
floating: true,
|
||||
pinned: true,
|
||||
expandedHeight: 200.0,
|
||||
@@ -180,6 +90,7 @@ class _DocumentDetailsPageState extends State<DocumentDetailsPage> {
|
||||
backgroundColor:
|
||||
Theme.of(context).colorScheme.primaryContainer,
|
||||
tabBar: TabBar(
|
||||
isScrollable: true,
|
||||
tabs: [
|
||||
Tab(
|
||||
child: Text(
|
||||
@@ -208,6 +119,18 @@ class _DocumentDetailsPageState extends State<DocumentDetailsPage> {
|
||||
.onPrimaryContainer),
|
||||
),
|
||||
),
|
||||
Tab(
|
||||
child: Text(
|
||||
S
|
||||
.of(context)
|
||||
.documentDetailsPageTabSimilarDocumentsLabel,
|
||||
style: TextStyle(
|
||||
color: Theme.of(context)
|
||||
.colorScheme
|
||||
.onPrimaryContainer,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
@@ -215,19 +138,27 @@ class _DocumentDetailsPageState extends State<DocumentDetailsPage> {
|
||||
],
|
||||
body: BlocBuilder<DocumentDetailsCubit, DocumentDetailsState>(
|
||||
builder: (context, state) {
|
||||
return TabBarView(
|
||||
children: [
|
||||
_buildDocumentOverview(
|
||||
state.document,
|
||||
),
|
||||
_buildDocumentContentView(
|
||||
state.document,
|
||||
state,
|
||||
),
|
||||
_buildDocumentMetaDataView(
|
||||
state.document,
|
||||
),
|
||||
],
|
||||
return BlocProvider(
|
||||
create: (context) => SimilarDocumentsCubit(
|
||||
context.read(),
|
||||
context.read(),
|
||||
documentId: state.document.id,
|
||||
),
|
||||
child: TabBarView(
|
||||
children: [
|
||||
_buildDocumentOverview(
|
||||
state.document,
|
||||
),
|
||||
_buildDocumentContentView(
|
||||
state.document,
|
||||
state,
|
||||
),
|
||||
_buildDocumentMetaDataView(
|
||||
state.document,
|
||||
),
|
||||
const SimilarDocumentsView(),
|
||||
],
|
||||
),
|
||||
).paddedSymmetrically(horizontal: 8);
|
||||
},
|
||||
),
|
||||
@@ -237,6 +168,94 @@ class _DocumentDetailsPageState extends State<DocumentDetailsPage> {
|
||||
);
|
||||
}
|
||||
|
||||
BlocBuilder<DocumentDetailsCubit, DocumentDetailsState> _buildAppBar() {
|
||||
return BlocBuilder<DocumentDetailsCubit, DocumentDetailsState>(
|
||||
builder: (context, state) {
|
||||
final _filteredSuggestions =
|
||||
state.suggestions.documentDifference(state.document);
|
||||
return BlocBuilder<ConnectivityCubit, ConnectivityState>(
|
||||
builder: (context, connectivityState) {
|
||||
if (!connectivityState.isConnected) {
|
||||
return Container();
|
||||
}
|
||||
return b.Badge(
|
||||
position: b.BadgePosition.topEnd(top: -12, end: -6),
|
||||
showBadge: _filteredSuggestions.hasSuggestions,
|
||||
child: Tooltip(
|
||||
message: S.of(context).documentDetailsPageEditTooltip,
|
||||
preferBelow: false,
|
||||
verticalOffset: 40,
|
||||
child: FloatingActionButton(
|
||||
child: const Icon(Icons.edit),
|
||||
onPressed: () => _onEdit(state.document),
|
||||
),
|
||||
),
|
||||
badgeContent: Text(
|
||||
'${_filteredSuggestions.suggestionsCount}',
|
||||
style: const TextStyle(
|
||||
color: Colors.white,
|
||||
),
|
||||
),
|
||||
badgeColor: Colors.red,
|
||||
);
|
||||
},
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
BlocBuilder<DocumentDetailsCubit, DocumentDetailsState> _buildBottomAppBar() {
|
||||
return BlocBuilder<DocumentDetailsCubit, DocumentDetailsState>(
|
||||
builder: (context, state) {
|
||||
return BottomAppBar(
|
||||
child: BlocBuilder<ConnectivityCubit, ConnectivityState>(
|
||||
builder: (context, connectivityState) {
|
||||
final isConnected = connectivityState.isConnected;
|
||||
return Row(
|
||||
mainAxisAlignment: MainAxisAlignment.start,
|
||||
children: [
|
||||
IconButton(
|
||||
tooltip: S.of(context).documentDetailsPageDeleteTooltip,
|
||||
icon: const Icon(Icons.delete),
|
||||
onPressed: widget.allowEdit && isConnected
|
||||
? () => _onDelete(state.document)
|
||||
: null,
|
||||
).paddedSymmetrically(horizontal: 4),
|
||||
Tooltip(
|
||||
message: S.of(context).documentDetailsPageDownloadTooltip,
|
||||
child: DocumentDownloadButton(
|
||||
document: state.document,
|
||||
enabled: isConnected,
|
||||
),
|
||||
),
|
||||
IconButton(
|
||||
tooltip: S.of(context).documentDetailsPagePreviewTooltip,
|
||||
icon: const Icon(Icons.visibility),
|
||||
onPressed:
|
||||
isConnected ? () => _onOpen(state.document) : null,
|
||||
).paddedOnly(right: 4.0),
|
||||
IconButton(
|
||||
tooltip: S
|
||||
.of(context)
|
||||
.documentDetailsPageOpenInSystemViewerTooltip,
|
||||
icon: const Icon(Icons.open_in_new),
|
||||
onPressed: isConnected ? _onOpenFileInSystemViewer : null,
|
||||
).paddedOnly(right: 4.0),
|
||||
IconButton(
|
||||
tooltip: S.of(context).documentDetailsPageShareTooltip,
|
||||
icon: const Icon(Icons.share),
|
||||
onPressed:
|
||||
isConnected ? () => _onShare(state.document) : null,
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _onEdit(DocumentModel document) async {
|
||||
{
|
||||
final cubit = context.read<DocumentDetailsCubit>();
|
||||
@@ -253,6 +272,7 @@ class _DocumentDetailsPageState extends State<DocumentDetailsPage> {
|
||||
documentTypeRepository: context.read(),
|
||||
storagePathRepository: context.read(),
|
||||
tagRepository: context.read(),
|
||||
notifier: context.read(),
|
||||
),
|
||||
),
|
||||
BlocProvider<DocumentDetailsCubit>.value(
|
||||
@@ -263,7 +283,7 @@ class _DocumentDetailsPageState extends State<DocumentDetailsPage> {
|
||||
listenWhen: (previous, current) =>
|
||||
previous.document != current.document,
|
||||
listener: (context, state) {
|
||||
cubit.replaceDocument(state.document);
|
||||
cubit.replace(state.document);
|
||||
},
|
||||
child: BlocBuilder<DocumentDetailsCubit, DocumentDetailsState>(
|
||||
builder: (context, state) {
|
||||
@@ -306,7 +326,7 @@ class _DocumentDetailsPageState extends State<DocumentDetailsPage> {
|
||||
);
|
||||
}
|
||||
return FutureBuilder<DocumentMetaData>(
|
||||
future: context.read<PaperlessDocumentsApi>().getMetaData(document),
|
||||
future: _metaData,
|
||||
builder: (context, snapshot) {
|
||||
if (!snapshot.hasData) {
|
||||
return const Center(child: CircularProgressIndicator());
|
||||
@@ -430,7 +450,7 @@ class _DocumentDetailsPageState extends State<DocumentDetailsPage> {
|
||||
visible: document.documentType != null,
|
||||
child: _DetailsItem(
|
||||
label: S.of(context).documentDocumentTypePropertyLabel,
|
||||
content: LabelText<DocumentType, DocumentTypeRepositoryState>(
|
||||
content: LabelText<DocumentType>(
|
||||
style: Theme.of(context).textTheme.bodyLarge,
|
||||
id: document.documentType,
|
||||
),
|
||||
@@ -440,7 +460,7 @@ class _DocumentDetailsPageState extends State<DocumentDetailsPage> {
|
||||
visible: document.correspondent != null,
|
||||
child: _DetailsItem(
|
||||
label: S.of(context).documentCorrespondentPropertyLabel,
|
||||
content: LabelText<Correspondent, CorrespondentRepositoryState>(
|
||||
content: LabelText<Correspondent>(
|
||||
style: Theme.of(context).textTheme.bodyLarge,
|
||||
id: document.correspondent,
|
||||
),
|
||||
@@ -451,7 +471,6 @@ class _DocumentDetailsPageState extends State<DocumentDetailsPage> {
|
||||
child: _DetailsItem(
|
||||
label: S.of(context).documentStoragePathPropertyLabel,
|
||||
content: StoragePathWidget(
|
||||
isClickable: widget.isLabelClickable,
|
||||
pathId: document.storagePath,
|
||||
),
|
||||
).paddedSymmetrically(vertical: 16),
|
||||
@@ -465,34 +484,10 @@ class _DocumentDetailsPageState extends State<DocumentDetailsPage> {
|
||||
child: TagsWidget(
|
||||
isClickable: widget.isLabelClickable,
|
||||
tagIds: document.tags,
|
||||
onTagSelected: (int tagId) {},
|
||||
),
|
||||
),
|
||||
).paddedSymmetrically(vertical: 16),
|
||||
),
|
||||
// _separator(),
|
||||
// FutureBuilder<List<SimilarDocumentModel>>(
|
||||
// future: getIt<DocumentRepository>().findSimilar(document.id),
|
||||
// builder: (context, snapshot) {
|
||||
// if (!snapshot.hasData) {
|
||||
// return CircularProgressIndicator();
|
||||
// }
|
||||
// return ExpansionTile(
|
||||
// tilePadding: const EdgeInsets.symmetric(horizontal: 8.0),
|
||||
// title: Text(
|
||||
// S.of(context).documentDetailsPageSimilarDocumentsLabel,
|
||||
// style:
|
||||
// Theme.of(context).textTheme.headline5?.copyWith(fontWeight: FontWeight.bold),
|
||||
// ),
|
||||
// children: snapshot.data!
|
||||
// .map((e) => DocumentListItem(
|
||||
// document: e,
|
||||
// onTap: (doc) {},
|
||||
// isSelected: false,
|
||||
// isAtLeastOneSelected: false))
|
||||
// .toList(),
|
||||
// );
|
||||
// }),
|
||||
],
|
||||
);
|
||||
}
|
||||
@@ -549,15 +544,6 @@ class _DocumentDetailsPageState extends State<DocumentDetailsPage> {
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
static String formatBytes(int bytes, int decimals) {
|
||||
if (bytes <= 0) return "0 B";
|
||||
const suffixes = ["B", "KB", "MB", "GB", "TB", "PB", "EB", "ZB", "YB"];
|
||||
var i = (log(bytes) / log(1024)).floor();
|
||||
return ((bytes / pow(1024, i)).toStringAsFixed(decimals)) +
|
||||
' ' +
|
||||
suffixes[i];
|
||||
}
|
||||
}
|
||||
|
||||
class _DetailsItem extends StatelessWidget {
|
||||
|
||||
@@ -5,7 +5,8 @@ import 'package:paperless_api/paperless_api.dart';
|
||||
import 'package:paperless_mobile/core/service/file_service.dart';
|
||||
import 'package:paperless_mobile/extensions/flutter_extensions.dart';
|
||||
import 'package:paperless_mobile/generated/l10n.dart';
|
||||
import 'package:paperless_mobile/util.dart';
|
||||
import 'package:paperless_mobile/helpers/message_helpers.dart';
|
||||
import 'package:paperless_mobile/constants.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
|
||||
class DocumentDownloadButton extends StatefulWidget {
|
||||
@@ -47,20 +48,24 @@ class _DocumentDownloadButtonState extends State<DocumentDownloadButton> {
|
||||
return;
|
||||
}
|
||||
setState(() => _isDownloadPending = true);
|
||||
final service = context.read<PaperlessDocumentsApi>();
|
||||
try {
|
||||
final bytes =
|
||||
await context.read<PaperlessDocumentsApi>().download(document);
|
||||
final bytes = await service.download(document);
|
||||
final meta = await service.getMetaData(document);
|
||||
final Directory dir = await FileService.downloadsDirectory;
|
||||
String filePath = "${dir.path}/${document.originalFileName}";
|
||||
//TODO: Add replacement mechanism here (ask user if file should be replaced if exists)
|
||||
await File(filePath).writeAsBytes(bytes);
|
||||
String filePath = "${dir.path}/${meta.mediaFilename}";
|
||||
final createdFile = File(filePath);
|
||||
createdFile.createSync(recursive: true);
|
||||
createdFile.writeAsBytesSync(bytes);
|
||||
showSnackBar(context, S.of(context).documentDownloadSuccessMessage);
|
||||
} on PaperlessServerException catch (error, stackTrace) {
|
||||
showErrorMessage(context, error, stackTrace);
|
||||
} catch (error) {
|
||||
showGenericError(context, error);
|
||||
} finally {
|
||||
setState(() => _isDownloadPending = false);
|
||||
if (mounted) {
|
||||
setState(() => _isDownloadPending = false);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,85 @@
|
||||
import 'package:collection/collection.dart';
|
||||
import 'package:hydrated_bloc/hydrated_bloc.dart';
|
||||
import 'package:paperless_api/paperless_api.dart';
|
||||
import 'package:paperless_mobile/core/notifier/document_changed_notifier.dart';
|
||||
import 'package:paperless_mobile/features/document_search/cubit/document_search_state.dart';
|
||||
import 'package:paperless_mobile/features/paged_document_view/paged_documents_mixin.dart';
|
||||
|
||||
class DocumentSearchCubit extends HydratedCubit<DocumentSearchState>
|
||||
with PagedDocumentsMixin {
|
||||
@override
|
||||
final PaperlessDocumentsApi api;
|
||||
@override
|
||||
final DocumentChangedNotifier notifier;
|
||||
|
||||
DocumentSearchCubit(this.api, this.notifier)
|
||||
: super(const DocumentSearchState()) {
|
||||
notifier.subscribe(
|
||||
this,
|
||||
onDeleted: remove,
|
||||
onUpdated: replace,
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> search(String query) async {
|
||||
emit(state.copyWith(
|
||||
isLoading: true,
|
||||
suggestions: [],
|
||||
view: SearchView.results,
|
||||
));
|
||||
final searchFilter = DocumentFilter(
|
||||
query: TextQuery.titleAndContent(query),
|
||||
);
|
||||
|
||||
await updateFilter(filter: searchFilter);
|
||||
emit(
|
||||
state.copyWith(
|
||||
searchHistory: [
|
||||
query,
|
||||
...state.searchHistory
|
||||
.whereNot((previousQuery) => previousQuery == query)
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> suggest(String query) async {
|
||||
emit(
|
||||
state.copyWith(
|
||||
isLoading: true,
|
||||
view: SearchView.suggestions,
|
||||
value: [],
|
||||
suggestions: [],
|
||||
),
|
||||
);
|
||||
final suggestions = await api.autocomplete(query);
|
||||
emit(state.copyWith(
|
||||
suggestions: suggestions,
|
||||
isLoading: false,
|
||||
));
|
||||
}
|
||||
|
||||
void reset() {
|
||||
emit(state.copyWith(
|
||||
view: SearchView.suggestions,
|
||||
suggestions: [],
|
||||
isLoading: false,
|
||||
));
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> close() {
|
||||
notifier.unsubscribe(this);
|
||||
return super.close();
|
||||
}
|
||||
|
||||
@override
|
||||
DocumentSearchState? fromJson(Map<String, dynamic> json) {
|
||||
return DocumentSearchState.fromJson(json);
|
||||
}
|
||||
|
||||
@override
|
||||
Map<String, dynamic>? toJson(DocumentSearchState state) {
|
||||
return state.toJson();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
import 'package:equatable/equatable.dart';
|
||||
import 'package:json_annotation/json_annotation.dart';
|
||||
import 'package:paperless_api/paperless_api.dart';
|
||||
import 'package:paperless_mobile/features/paged_document_view/model/paged_documents_state.dart';
|
||||
|
||||
part 'document_search_state.g.dart';
|
||||
|
||||
enum SearchView {
|
||||
suggestions,
|
||||
results;
|
||||
}
|
||||
|
||||
@JsonSerializable(ignoreUnannotated: true)
|
||||
class DocumentSearchState extends PagedDocumentsState {
|
||||
@JsonKey()
|
||||
final List<String> searchHistory;
|
||||
final SearchView view;
|
||||
final List<String> suggestions;
|
||||
const DocumentSearchState({
|
||||
this.view = SearchView.suggestions,
|
||||
this.searchHistory = const [],
|
||||
this.suggestions = const [],
|
||||
super.filter,
|
||||
super.hasLoaded,
|
||||
super.isLoading,
|
||||
super.value,
|
||||
});
|
||||
|
||||
@override
|
||||
List<Object?> get props => [
|
||||
...super.props,
|
||||
searchHistory,
|
||||
suggestions,
|
||||
view,
|
||||
];
|
||||
|
||||
@override
|
||||
DocumentSearchState copyWithPaged({
|
||||
bool? hasLoaded,
|
||||
bool? isLoading,
|
||||
List<PagedSearchResult<DocumentModel>>? value,
|
||||
DocumentFilter? filter,
|
||||
}) {
|
||||
return copyWith(
|
||||
hasLoaded: hasLoaded,
|
||||
isLoading: isLoading,
|
||||
filter: filter,
|
||||
value: value,
|
||||
);
|
||||
}
|
||||
|
||||
DocumentSearchState copyWith({
|
||||
List<String>? searchHistory,
|
||||
bool? hasLoaded,
|
||||
bool? isLoading,
|
||||
List<PagedSearchResult<DocumentModel>>? value,
|
||||
DocumentFilter? filter,
|
||||
List<String>? suggestions,
|
||||
SearchView? view,
|
||||
}) {
|
||||
return DocumentSearchState(
|
||||
value: value ?? this.value,
|
||||
filter: filter ?? this.filter,
|
||||
hasLoaded: hasLoaded ?? this.hasLoaded,
|
||||
isLoading: isLoading ?? this.isLoading,
|
||||
searchHistory: searchHistory ?? this.searchHistory,
|
||||
view: view ?? this.view,
|
||||
suggestions: suggestions ?? this.suggestions,
|
||||
);
|
||||
}
|
||||
|
||||
factory DocumentSearchState.fromJson(Map<String, dynamic> json) =>
|
||||
_$DocumentSearchStateFromJson(json);
|
||||
|
||||
Map<String, dynamic> toJson() => _$DocumentSearchStateToJson(this);
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
// GENERATED CODE - DO NOT MODIFY BY HAND
|
||||
|
||||
part of 'document_search_state.dart';
|
||||
|
||||
// **************************************************************************
|
||||
// JsonSerializableGenerator
|
||||
// **************************************************************************
|
||||
|
||||
DocumentSearchState _$DocumentSearchStateFromJson(Map<String, dynamic> json) =>
|
||||
DocumentSearchState(
|
||||
searchHistory: (json['searchHistory'] as List<dynamic>?)
|
||||
?.map((e) => e as String)
|
||||
.toList() ??
|
||||
const [],
|
||||
);
|
||||
|
||||
Map<String, dynamic> _$DocumentSearchStateToJson(
|
||||
DocumentSearchState instance) =>
|
||||
<String, dynamic>{
|
||||
'searchHistory': instance.searchHistory,
|
||||
};
|
||||
182
lib/features/document_search/view/document_search_page.dart
Normal file
182
lib/features/document_search/view/document_search_page.dart
Normal file
@@ -0,0 +1,182 @@
|
||||
import 'package:collection/collection.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
import 'package:paperless_api/paperless_api.dart';
|
||||
import 'package:paperless_mobile/extensions/flutter_extensions.dart';
|
||||
import 'package:paperless_mobile/features/document_search/cubit/document_search_cubit.dart';
|
||||
import 'package:paperless_mobile/features/document_search/cubit/document_search_state.dart';
|
||||
import 'package:paperless_mobile/features/documents/view/widgets/adaptive_documents_view.dart';
|
||||
import 'package:paperless_mobile/generated/l10n.dart';
|
||||
import 'package:paperless_mobile/routes/document_details_route.dart';
|
||||
|
||||
Future<void> showDocumentSearchPage(BuildContext context) {
|
||||
return Navigator.of(context).push(
|
||||
MaterialPageRoute(
|
||||
builder: (context) => BlocProvider(
|
||||
create: (context) => DocumentSearchCubit(
|
||||
context.read(),
|
||||
context.read(),
|
||||
),
|
||||
child: const DocumentSearchPage(),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
class DocumentSearchPage extends StatefulWidget {
|
||||
const DocumentSearchPage({super.key});
|
||||
|
||||
@override
|
||||
State<DocumentSearchPage> createState() => _DocumentSearchPageState();
|
||||
}
|
||||
|
||||
class _DocumentSearchPageState extends State<DocumentSearchPage> {
|
||||
final _queryController = TextEditingController(text: '');
|
||||
|
||||
String get query => _queryController.text;
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
return Scaffold(
|
||||
appBar: AppBar(
|
||||
backgroundColor: theme.colorScheme.surface,
|
||||
toolbarHeight: 72,
|
||||
leading: BackButton(
|
||||
color: theme.colorScheme.onSurface,
|
||||
),
|
||||
title: TextField(
|
||||
autofocus: true,
|
||||
style: theme.textTheme.bodyLarge?.apply(
|
||||
color: theme.colorScheme.onSurface,
|
||||
),
|
||||
decoration: InputDecoration(
|
||||
contentPadding: EdgeInsets.zero,
|
||||
hintStyle: theme.textTheme.bodyLarge?.apply(
|
||||
color: theme.colorScheme.onSurfaceVariant,
|
||||
),
|
||||
hintText: S.of(context).documentSearchSearchDocuments,
|
||||
border: InputBorder.none,
|
||||
),
|
||||
controller: _queryController,
|
||||
onChanged: context.read<DocumentSearchCubit>().suggest,
|
||||
textInputAction: TextInputAction.search,
|
||||
onSubmitted: (query) {
|
||||
FocusScope.of(context).unfocus();
|
||||
context.read<DocumentSearchCubit>().search(query);
|
||||
},
|
||||
),
|
||||
actions: [
|
||||
IconButton(
|
||||
color: theme.colorScheme.onSurfaceVariant,
|
||||
icon: const Icon(Icons.clear),
|
||||
onPressed: () {
|
||||
context.read<DocumentSearchCubit>().reset();
|
||||
_queryController.clear();
|
||||
},
|
||||
).padded(),
|
||||
],
|
||||
bottom: PreferredSize(
|
||||
preferredSize: const Size.fromHeight(1),
|
||||
child: Divider(
|
||||
color: theme.colorScheme.outline,
|
||||
),
|
||||
),
|
||||
),
|
||||
body: BlocBuilder<DocumentSearchCubit, DocumentSearchState>(
|
||||
builder: (context, state) {
|
||||
switch (state.view) {
|
||||
case SearchView.suggestions:
|
||||
return _buildSuggestionsView(state);
|
||||
case SearchView.results:
|
||||
return _buildResultsView(state);
|
||||
}
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildSuggestionsView(DocumentSearchState state) {
|
||||
final suggestions = state.suggestions
|
||||
.whereNot((element) => state.searchHistory.contains(element))
|
||||
.toList();
|
||||
final historyMatches = state.searchHistory
|
||||
.where(
|
||||
(element) => element.startsWith(query),
|
||||
)
|
||||
.toList();
|
||||
return CustomScrollView(
|
||||
slivers: [
|
||||
SliverList(
|
||||
delegate: SliverChildBuilderDelegate(
|
||||
(context, index) => ListTile(
|
||||
title: Text(historyMatches[index]),
|
||||
leading: const Icon(Icons.history),
|
||||
onTap: () => _selectSuggestion(historyMatches[index]),
|
||||
),
|
||||
childCount: historyMatches.length,
|
||||
),
|
||||
),
|
||||
if (state.isLoading)
|
||||
const SliverToBoxAdapter(
|
||||
child: Center(
|
||||
child: CircularProgressIndicator(),
|
||||
),
|
||||
)
|
||||
else
|
||||
SliverList(
|
||||
delegate: SliverChildBuilderDelegate(
|
||||
(context, index) => ListTile(
|
||||
title: Text(suggestions[index]),
|
||||
leading: const Icon(Icons.search),
|
||||
onTap: () => _selectSuggestion(suggestions[index]),
|
||||
),
|
||||
childCount: suggestions.length,
|
||||
),
|
||||
)
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildResultsView(DocumentSearchState state) {
|
||||
final header = Text(
|
||||
S.of(context).documentSearchResults,
|
||||
style: Theme.of(context).textTheme.labelSmall,
|
||||
).padded();
|
||||
return CustomScrollView(
|
||||
slivers: [
|
||||
SliverToBoxAdapter(child: header),
|
||||
if (state.hasLoaded && !state.isLoading && state.documents.isEmpty)
|
||||
SliverToBoxAdapter(
|
||||
child: Center(
|
||||
child: Text(S.of(context).documentSearchNoMatchesFound),
|
||||
),
|
||||
)
|
||||
else
|
||||
SliverAdaptiveDocumentsView(
|
||||
documents: state.documents,
|
||||
hasInternetConnection: true,
|
||||
isLabelClickable: false,
|
||||
isLoading: state.isLoading,
|
||||
hasLoaded: state.hasLoaded,
|
||||
enableHeroAnimation: false,
|
||||
onTap: (document) {
|
||||
Navigator.pushNamed(
|
||||
context,
|
||||
DocumentDetailsRoute.routeName,
|
||||
arguments: DocumentDetailsRouteArguments(
|
||||
document: document,
|
||||
isLabelClickable: false,
|
||||
),
|
||||
);
|
||||
},
|
||||
)
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
void _selectSuggestion(String suggestion) {
|
||||
_queryController.text = suggestion;
|
||||
context.read<DocumentSearchCubit>().search(suggestion);
|
||||
FocusScope.of(context).unfocus();
|
||||
}
|
||||
}
|
||||
@@ -8,29 +8,23 @@ import 'package:paperless_mobile/core/repository/label_repository.dart';
|
||||
import 'package:paperless_mobile/core/repository/state/impl/correspondent_repository_state.dart';
|
||||
import 'package:paperless_mobile/core/repository/state/impl/document_type_repository_state.dart';
|
||||
import 'package:paperless_mobile/core/repository/state/impl/tag_repository_state.dart';
|
||||
import 'package:paperless_mobile/core/store/local_vault.dart';
|
||||
|
||||
part 'document_upload_state.dart';
|
||||
|
||||
class DocumentUploadCubit extends Cubit<DocumentUploadState> {
|
||||
final PaperlessDocumentsApi _documentApi;
|
||||
|
||||
final LabelRepository<Tag, TagRepositoryState> _tagRepository;
|
||||
final LabelRepository<Correspondent, CorrespondentRepositoryState>
|
||||
_correspondentRepository;
|
||||
final LabelRepository<DocumentType, DocumentTypeRepositoryState>
|
||||
_documentTypeRepository;
|
||||
final LabelRepository<Tag> _tagRepository;
|
||||
final LabelRepository<Correspondent> _correspondentRepository;
|
||||
final LabelRepository<DocumentType> _documentTypeRepository;
|
||||
|
||||
final List<StreamSubscription> _subs = [];
|
||||
|
||||
DocumentUploadCubit({
|
||||
required LocalVault localVault,
|
||||
required PaperlessDocumentsApi documentApi,
|
||||
required LabelRepository<Tag, TagRepositoryState> tagRepository,
|
||||
required LabelRepository<Correspondent, CorrespondentRepositoryState>
|
||||
correspondentRepository,
|
||||
required LabelRepository<DocumentType, DocumentTypeRepositoryState>
|
||||
documentTypeRepository,
|
||||
required LabelRepository<Tag> tagRepository,
|
||||
required LabelRepository<Correspondent> correspondentRepository,
|
||||
required LabelRepository<DocumentType> documentTypeRepository,
|
||||
}) : _documentApi = documentApi,
|
||||
_tagRepository = tagRepository,
|
||||
_correspondentRepository = correspondentRepository,
|
||||
|
||||
@@ -8,10 +8,7 @@ import 'package:intl/date_symbol_data_local.dart';
|
||||
import 'package:intl/intl.dart';
|
||||
import 'package:paperless_api/paperless_api.dart';
|
||||
import 'package:paperless_mobile/core/repository/label_repository.dart';
|
||||
import 'package:paperless_mobile/core/repository/state/impl/correspondent_repository_state.dart';
|
||||
import 'package:paperless_mobile/core/repository/state/impl/document_type_repository_state.dart';
|
||||
import 'package:paperless_mobile/core/type/types.dart';
|
||||
import 'package:paperless_mobile/core/widgets/hint_card.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,7 +16,7 @@ import 'package:paperless_mobile/features/edit_label/view/impl/add_document_type
|
||||
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.dart';
|
||||
import 'package:paperless_mobile/util.dart';
|
||||
import 'package:paperless_mobile/helpers/message_helpers.dart';
|
||||
|
||||
class DocumentUploadPreparationPage extends StatefulWidget {
|
||||
final Uint8List fileBytes;
|
||||
@@ -172,9 +169,8 @@ class _DocumentUploadPreparationPageState
|
||||
formBuilderState: _formKey.currentState,
|
||||
labelCreationWidgetBuilder: (initialName) =>
|
||||
RepositoryProvider(
|
||||
create: (context) => context.read<
|
||||
LabelRepository<DocumentType,
|
||||
DocumentTypeRepositoryState>>(),
|
||||
create: (context) =>
|
||||
context.read<LabelRepository<DocumentType>>(),
|
||||
child: AddDocumentTypePage(initialName: initialName),
|
||||
),
|
||||
textFieldLabel:
|
||||
@@ -188,9 +184,8 @@ class _DocumentUploadPreparationPageState
|
||||
formBuilderState: _formKey.currentState,
|
||||
labelCreationWidgetBuilder: (initialName) =>
|
||||
RepositoryProvider(
|
||||
create: (context) => context.read<
|
||||
LabelRepository<Correspondent,
|
||||
CorrespondentRepositoryState>>(),
|
||||
create: (context) =>
|
||||
context.read<LabelRepository<Correspondent>>(),
|
||||
child: AddCorrespondentPage(initialName: initialName),
|
||||
),
|
||||
textFieldLabel:
|
||||
|
||||
@@ -1,27 +1,37 @@
|
||||
import 'dart:async';
|
||||
import 'dart:developer';
|
||||
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:hydrated_bloc/hydrated_bloc.dart';
|
||||
import 'package:paperless_api/paperless_api.dart';
|
||||
import 'package:paperless_mobile/core/repository/saved_view_repository.dart';
|
||||
import 'package:paperless_mobile/core/notifier/document_changed_notifier.dart';
|
||||
import 'package:paperless_mobile/features/documents/bloc/documents_state.dart';
|
||||
import 'package:paperless_mobile/features/paged_document_view/documents_paging_mixin.dart';
|
||||
import 'package:paperless_mobile/features/paged_document_view/paged_documents_mixin.dart';
|
||||
|
||||
class DocumentsCubit extends HydratedCubit<DocumentsState>
|
||||
with DocumentsPagingMixin {
|
||||
with PagedDocumentsMixin {
|
||||
@override
|
||||
final PaperlessDocumentsApi api;
|
||||
|
||||
final SavedViewRepository _savedViewRepository;
|
||||
@override
|
||||
final DocumentChangedNotifier notifier;
|
||||
|
||||
DocumentsCubit(this.api, this._savedViewRepository)
|
||||
: super(const DocumentsState());
|
||||
DocumentsCubit(this.api, this.notifier) : super(const DocumentsState()) {
|
||||
notifier.subscribe(
|
||||
this,
|
||||
onUpdated: replace,
|
||||
onDeleted: remove,
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> bulkRemove(List<DocumentModel> documents) async {
|
||||
log("[DocumentsCubit] bulkRemove");
|
||||
Future<void> bulkDelete(List<DocumentModel> documents) async {
|
||||
debugPrint("[DocumentsCubit] bulkRemove");
|
||||
await api.bulkAction(
|
||||
BulkDeleteAction(documents.map((doc) => doc.id)),
|
||||
);
|
||||
for (final deletedDoc in documents) {
|
||||
notifier.notifyDeleted(deletedDoc);
|
||||
}
|
||||
await reload();
|
||||
}
|
||||
|
||||
@@ -30,7 +40,7 @@ class DocumentsCubit extends HydratedCubit<DocumentsState>
|
||||
Iterable<int> addTags = const [],
|
||||
Iterable<int> removeTags = const [],
|
||||
}) async {
|
||||
log("[DocumentsCubit] bulkEditTags");
|
||||
debugPrint("[DocumentsCubit] bulkEditTags");
|
||||
await api.bulkAction(BulkModifyTagsAction(
|
||||
documents.map((doc) => doc.id),
|
||||
addTags: addTags,
|
||||
@@ -40,7 +50,7 @@ class DocumentsCubit extends HydratedCubit<DocumentsState>
|
||||
}
|
||||
|
||||
void toggleDocumentSelection(DocumentModel model) {
|
||||
log("[DocumentsCubit] toggleSelection");
|
||||
debugPrint("[DocumentsCubit] toggleSelection");
|
||||
if (state.selectedIds.contains(model.id)) {
|
||||
emit(
|
||||
state.copyWith(
|
||||
@@ -50,54 +60,25 @@ class DocumentsCubit extends HydratedCubit<DocumentsState>
|
||||
),
|
||||
);
|
||||
} else {
|
||||
emit(
|
||||
state.copyWith(selection: [...state.selection, model]),
|
||||
);
|
||||
emit(state.copyWith(selection: [...state.selection, model]));
|
||||
}
|
||||
}
|
||||
|
||||
void resetSelection() {
|
||||
log("[DocumentsCubit] resetSelection");
|
||||
debugPrint("[DocumentsCubit] resetSelection");
|
||||
emit(state.copyWith(selection: []));
|
||||
}
|
||||
|
||||
void reset() {
|
||||
log("[DocumentsCubit] reset");
|
||||
debugPrint("[DocumentsCubit] reset");
|
||||
emit(const DocumentsState());
|
||||
}
|
||||
|
||||
Future<void> selectView(int id) async {
|
||||
emit(state.copyWith(isLoading: true));
|
||||
try {
|
||||
final filter =
|
||||
_savedViewRepository.current?.values[id]?.toDocumentFilter();
|
||||
if (filter == null) {
|
||||
return;
|
||||
}
|
||||
final results = await api.findAll(filter.copyWith(page: 1));
|
||||
emit(
|
||||
DocumentsState(
|
||||
filter: filter,
|
||||
hasLoaded: true,
|
||||
isLoading: false,
|
||||
selectedSavedViewId: id,
|
||||
value: [results],
|
||||
),
|
||||
);
|
||||
} finally {
|
||||
emit(state.copyWith(isLoading: false));
|
||||
}
|
||||
}
|
||||
|
||||
Future<Iterable<String>> autocomplete(String query) async {
|
||||
final res = await api.autocomplete(query);
|
||||
return res;
|
||||
}
|
||||
|
||||
void unselectView() {
|
||||
emit(state.copyWith(selectedSavedViewId: () => null));
|
||||
}
|
||||
|
||||
@override
|
||||
DocumentsState? fromJson(Map<String, dynamic> json) {
|
||||
return DocumentsState.fromJson(json);
|
||||
@@ -107,4 +88,10 @@ class DocumentsCubit extends HydratedCubit<DocumentsState>
|
||||
Map<String, dynamic>? toJson(DocumentsState state) {
|
||||
return state.toJson();
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> close() {
|
||||
notifier.unsubscribe(this);
|
||||
return super.close();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,16 +1,13 @@
|
||||
import 'package:json_annotation/json_annotation.dart';
|
||||
import 'package:paperless_api/paperless_api.dart';
|
||||
import 'package:paperless_mobile/features/paged_document_view/model/documents_paged_state.dart';
|
||||
import 'package:paperless_mobile/features/paged_document_view/model/paged_documents_state.dart';
|
||||
|
||||
class DocumentsState extends DocumentsPagedState {
|
||||
final int? selectedSavedViewId;
|
||||
|
||||
@JsonKey(ignore: true)
|
||||
class DocumentsState extends PagedDocumentsState {
|
||||
@JsonKey(includeFromJson: true, includeToJson: false)
|
||||
final List<DocumentModel> selection;
|
||||
|
||||
const DocumentsState({
|
||||
this.selection = const [],
|
||||
this.selectedSavedViewId,
|
||||
super.value = const [],
|
||||
super.filter = const DocumentFilter(),
|
||||
super.hasLoaded = false,
|
||||
@@ -25,7 +22,6 @@ class DocumentsState extends DocumentsPagedState {
|
||||
List<PagedSearchResult<DocumentModel>>? value,
|
||||
DocumentFilter? filter,
|
||||
List<DocumentModel>? selection,
|
||||
int? Function()? selectedSavedViewId,
|
||||
}) {
|
||||
return DocumentsState(
|
||||
hasLoaded: hasLoaded ?? this.hasLoaded,
|
||||
@@ -33,20 +29,13 @@ class DocumentsState extends DocumentsPagedState {
|
||||
value: value ?? this.value,
|
||||
filter: filter ?? this.filter,
|
||||
selection: selection ?? this.selection,
|
||||
selectedSavedViewId: selectedSavedViewId != null
|
||||
? selectedSavedViewId.call()
|
||||
: this.selectedSavedViewId,
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
List<Object?> get props => [
|
||||
hasLoaded,
|
||||
filter,
|
||||
value,
|
||||
selection,
|
||||
isLoading,
|
||||
selectedSavedViewId,
|
||||
...super.props,
|
||||
];
|
||||
|
||||
Map<String, dynamic> toJson() {
|
||||
@@ -54,7 +43,6 @@ class DocumentsState extends DocumentsPagedState {
|
||||
'hasLoaded': hasLoaded,
|
||||
'isLoading': isLoading,
|
||||
'filter': filter.toJson(),
|
||||
'selectedSavedViewId': selectedSavedViewId,
|
||||
'value':
|
||||
value.map((e) => e.toJson(DocumentModelJsonConverter())).toList(),
|
||||
};
|
||||
@@ -65,7 +53,6 @@ class DocumentsState extends DocumentsPagedState {
|
||||
return DocumentsState(
|
||||
hasLoaded: json['hasLoaded'],
|
||||
isLoading: json['isLoading'],
|
||||
selectedSavedViewId: json['selectedSavedViewId'],
|
||||
value: (json['value'] as List<dynamic>)
|
||||
.map((e) =>
|
||||
PagedSearchResult.fromJsonT(e, DocumentModelJsonConverter()))
|
||||
|
||||
@@ -20,7 +20,8 @@ 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.dart';
|
||||
import 'package:paperless_mobile/util.dart';
|
||||
import 'package:paperless_mobile/helpers/message_helpers.dart';
|
||||
import 'package:paperless_mobile/constants.dart';
|
||||
|
||||
class DocumentEditPage extends StatefulWidget {
|
||||
final FieldSuggestions suggestions;
|
||||
@@ -159,8 +160,7 @@ class _DocumentEditPageState extends State<DocumentEditPage> {
|
||||
notAssignedSelectable: false,
|
||||
formBuilderState: _formKey.currentState,
|
||||
labelCreationWidgetBuilder: (initialValue) => RepositoryProvider(
|
||||
create: (context) => context.read<
|
||||
LabelRepository<StoragePath, StoragePathRepositoryState>>(),
|
||||
create: (context) => context.read<LabelRepository<StoragePath>>(),
|
||||
child: AddStoragePathPage(initalValue: initialValue),
|
||||
),
|
||||
textFieldLabel: S.of(context).documentStoragePathPropertyLabel,
|
||||
@@ -181,8 +181,7 @@ class _DocumentEditPageState extends State<DocumentEditPage> {
|
||||
notAssignedSelectable: false,
|
||||
formBuilderState: _formKey.currentState,
|
||||
labelCreationWidgetBuilder: (initialValue) => RepositoryProvider(
|
||||
create: (context) => context.read<
|
||||
LabelRepository<Correspondent, CorrespondentRepositoryState>>(),
|
||||
create: (context) => context.read<LabelRepository<Correspondent>>(),
|
||||
child: AddCorrespondentPage(initialName: initialValue),
|
||||
),
|
||||
textFieldLabel: S.of(context).documentCorrespondentPropertyLabel,
|
||||
@@ -214,8 +213,7 @@ class _DocumentEditPageState extends State<DocumentEditPage> {
|
||||
notAssignedSelectable: false,
|
||||
formBuilderState: _formKey.currentState,
|
||||
labelCreationWidgetBuilder: (currentInput) => RepositoryProvider(
|
||||
create: (context) => context.read<
|
||||
LabelRepository<DocumentType, DocumentTypeRepositoryState>>(),
|
||||
create: (context) => context.read<LabelRepository<DocumentType>>(),
|
||||
child: AddDocumentTypePage(
|
||||
initialName: currentInput,
|
||||
),
|
||||
@@ -290,7 +288,7 @@ class _DocumentEditPageState extends State<DocumentEditPage> {
|
||||
label: Text(S.of(context).documentCreatedPropertyLabel),
|
||||
),
|
||||
initialValue: initialCreatedAtDate,
|
||||
format: DateFormat("dd. MMMM yyyy"), //TODO: Localized date format
|
||||
format: DateFormat.yMMMMd(),
|
||||
initialEntryMode: DatePickerEntryMode.calendar,
|
||||
),
|
||||
if (_filteredSuggestions.hasSuggestedDates)
|
||||
|
||||
@@ -1,32 +1,33 @@
|
||||
import 'dart:developer';
|
||||
|
||||
import 'package:badges/badges.dart' as b;
|
||||
import 'package:collection/collection.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
import 'package:paperless_api/paperless_api.dart';
|
||||
import 'package:paperless_mobile/core/bloc/connectivity_cubit.dart';
|
||||
import 'package:paperless_mobile/core/repository/provider/label_repositories_provider.dart';
|
||||
import 'package:paperless_mobile/extensions/flutter_extensions.dart';
|
||||
import 'package:paperless_mobile/features/document_details/bloc/document_details_cubit.dart';
|
||||
import 'package:paperless_mobile/features/document_details/view/pages/document_details_page.dart';
|
||||
import 'package:paperless_mobile/features/app_drawer/view/app_drawer.dart';
|
||||
import 'package:paperless_mobile/features/document_search/view/document_search_page.dart';
|
||||
import 'package:paperless_mobile/features/documents/bloc/documents_cubit.dart';
|
||||
import 'package:paperless_mobile/features/documents/bloc/documents_state.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/list/adaptive_documents_view.dart';
|
||||
import 'package:paperless_mobile/features/documents/view/widgets/new_items_loading_widget.dart';
|
||||
import 'package:paperless_mobile/features/documents/view/widgets/search/document_filter_panel.dart';
|
||||
import 'package:paperless_mobile/features/documents/view/widgets/selection/bulk_delete_confirmation_dialog.dart';
|
||||
import 'package:paperless_mobile/features/documents/view/widgets/sort_documents_button.dart';
|
||||
import 'package:paperless_mobile/features/home/view/widget/app_drawer.dart';
|
||||
import 'package:paperless_mobile/features/labels/bloc/providers/labels_bloc_provider.dart';
|
||||
import 'package:paperless_mobile/features/login/bloc/authentication_cubit.dart';
|
||||
import 'package:paperless_mobile/features/saved_view/cubit/saved_view_cubit.dart';
|
||||
import 'package:paperless_mobile/features/saved_view/view/saved_view_selection_widget.dart';
|
||||
import 'package:paperless_mobile/features/saved_view/view/add_saved_view_page.dart';
|
||||
import 'package:paperless_mobile/features/saved_view/view/saved_view_list.dart';
|
||||
import 'package:paperless_mobile/features/search_app_bar/view/search_app_bar.dart';
|
||||
import 'package:paperless_mobile/features/settings/bloc/application_settings_cubit.dart';
|
||||
import 'package:paperless_mobile/features/settings/model/application_settings_state.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/tasks/cubit/task_status_cubit.dart';
|
||||
import 'package:paperless_mobile/generated/l10n.dart';
|
||||
import 'package:paperless_mobile/util.dart';
|
||||
import 'package:paperless_mobile/helpers/message_helpers.dart';
|
||||
import 'package:paperless_mobile/routes/document_details_route.dart';
|
||||
|
||||
class DocumentFilterIntent {
|
||||
final DocumentFilter? filter;
|
||||
@@ -38,6 +39,7 @@ class DocumentFilterIntent {
|
||||
});
|
||||
}
|
||||
|
||||
//TODO: Refactor this
|
||||
class DocumentsPage extends StatefulWidget {
|
||||
const DocumentsPage({Key? key}) : super(key: key);
|
||||
|
||||
@@ -45,52 +47,38 @@ class DocumentsPage extends StatefulWidget {
|
||||
State<DocumentsPage> createState() => _DocumentsPageState();
|
||||
}
|
||||
|
||||
class _DocumentsPageState extends State<DocumentsPage> {
|
||||
final ScrollController _scrollController = ScrollController();
|
||||
double _offset = 0;
|
||||
double _last = 0;
|
||||
class _DocumentsPageState extends State<DocumentsPage>
|
||||
with SingleTickerProviderStateMixin {
|
||||
late final TabController _tabController;
|
||||
|
||||
static const double _savedViewWidgetHeight = 80 + 16;
|
||||
int _currentTab = 0;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_tabController = TabController(
|
||||
length: 2,
|
||||
vsync: this,
|
||||
initialIndex: 0,
|
||||
);
|
||||
try {
|
||||
context.read<DocumentsCubit>().reload();
|
||||
context.read<SavedViewCubit>().reload();
|
||||
} on PaperlessServerException catch (error, stackTrace) {
|
||||
showErrorMessage(context, error, stackTrace);
|
||||
}
|
||||
_scrollController
|
||||
..addListener(_listenForScrollChanges)
|
||||
..addListener(_listenForLoadNewData);
|
||||
_tabController.addListener(_listenForTabChanges);
|
||||
}
|
||||
|
||||
void _listenForLoadNewData() {
|
||||
final currState = context.read<DocumentsCubit>().state;
|
||||
if (_scrollController.offset >=
|
||||
_scrollController.position.maxScrollExtent * 0.75 &&
|
||||
!currState.isLoading &&
|
||||
!currState.isLastPageLoaded) {
|
||||
_loadNewPage();
|
||||
}
|
||||
}
|
||||
|
||||
void _listenForScrollChanges() {
|
||||
final current = _scrollController.offset;
|
||||
_offset += _last - current;
|
||||
|
||||
if (_offset <= -_savedViewWidgetHeight) _offset = -_savedViewWidgetHeight;
|
||||
if (_offset >= 0) _offset = 0;
|
||||
_last = current;
|
||||
if (_offset <= 0 && _offset >= -_savedViewWidgetHeight) {
|
||||
setState(() {});
|
||||
}
|
||||
void _listenForTabChanges() {
|
||||
setState(() {
|
||||
_currentTab = _tabController.index;
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_scrollController.dispose();
|
||||
_tabController.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@@ -127,77 +115,8 @@ class _DocumentsPageState extends State<DocumentsPage> {
|
||||
}
|
||||
},
|
||||
builder: (context, connectivityState) {
|
||||
const linearProgressIndicatorHeight = 4.0;
|
||||
return Scaffold(
|
||||
drawer: BlocProvider.value(
|
||||
value: context.read<AuthenticationCubit>(),
|
||||
child: AppDrawer(
|
||||
afterInboxClosed: () => context.read<DocumentsCubit>().reload(),
|
||||
),
|
||||
),
|
||||
appBar: PreferredSize(
|
||||
preferredSize: const Size.fromHeight(
|
||||
kToolbarHeight + linearProgressIndicatorHeight,
|
||||
),
|
||||
child: BlocBuilder<DocumentsCubit, DocumentsState>(
|
||||
builder: (context, state) {
|
||||
if (state.selection.isEmpty) {
|
||||
return AppBar(
|
||||
title: Text(
|
||||
"${S.of(context).documentsPageTitle} (${_formatDocumentCount(state.count)})",
|
||||
),
|
||||
actions: [
|
||||
const SortDocumentsButton(),
|
||||
BlocBuilder<ApplicationSettingsCubit,
|
||||
ApplicationSettingsState>(
|
||||
builder: (context, settingsState) => IconButton(
|
||||
icon: Icon(
|
||||
settingsState.preferredViewType == ViewType.grid
|
||||
? Icons.list
|
||||
: Icons.grid_view_rounded,
|
||||
),
|
||||
onPressed: () {
|
||||
// Reset saved view widget position as scroll offset will be reset anyway.
|
||||
setState(() {
|
||||
_offset = 0;
|
||||
_last = 0;
|
||||
});
|
||||
final cubit =
|
||||
context.read<ApplicationSettingsCubit>();
|
||||
cubit.setViewType(
|
||||
cubit.state.preferredViewType.toggle());
|
||||
},
|
||||
),
|
||||
),
|
||||
],
|
||||
bottom: PreferredSize(
|
||||
preferredSize: const Size.fromHeight(
|
||||
linearProgressIndicatorHeight),
|
||||
child: state.isLoading && state.hasLoaded
|
||||
? const LinearProgressIndicator()
|
||||
: const SizedBox(height: 4.0),
|
||||
),
|
||||
);
|
||||
} else {
|
||||
return AppBar(
|
||||
leading: IconButton(
|
||||
icon: const Icon(Icons.close),
|
||||
onPressed: () =>
|
||||
context.read<DocumentsCubit>().resetSelection(),
|
||||
),
|
||||
title: Text(
|
||||
'${state.selection.length} ${S.of(context).documentsSelectedText}'),
|
||||
actions: [
|
||||
IconButton(
|
||||
icon: const Icon(Icons.delete),
|
||||
onPressed: () => _onDelete(context, state),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
},
|
||||
),
|
||||
),
|
||||
drawer: const AppDrawer(),
|
||||
floatingActionButton: BlocBuilder<DocumentsCubit, DocumentsState>(
|
||||
builder: (context, state) {
|
||||
final appliedFiltersCount = state.filter.appliedFiltersCount;
|
||||
@@ -212,10 +131,15 @@ class _DocumentsPageState extends State<DocumentsPage> {
|
||||
),
|
||||
animationType: b.BadgeAnimationType.fade,
|
||||
badgeColor: Colors.red,
|
||||
child: FloatingActionButton(
|
||||
child: const Icon(Icons.filter_alt_outlined),
|
||||
onPressed: _openDocumentFilter,
|
||||
),
|
||||
child: _currentTab == 0
|
||||
? FloatingActionButton(
|
||||
child: const Icon(Icons.filter_alt_outlined),
|
||||
onPressed: _openDocumentFilter,
|
||||
)
|
||||
: FloatingActionButton(
|
||||
child: const Icon(Icons.add),
|
||||
onPressed: () => _onCreateSavedView(state.filter),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
@@ -227,35 +151,200 @@ class _DocumentsPageState extends State<DocumentsPage> {
|
||||
}
|
||||
return false;
|
||||
},
|
||||
child: RefreshIndicator(
|
||||
onRefresh: _onRefresh,
|
||||
notificationPredicate: (_) => connectivityState.isConnected,
|
||||
child: BlocBuilder<TaskStatusCubit, TaskStatusState>(
|
||||
builder: (context, taskState) {
|
||||
return Stack(
|
||||
children: [
|
||||
_buildBody(connectivityState),
|
||||
Positioned(
|
||||
left: 0,
|
||||
right: 0,
|
||||
top: _offset,
|
||||
child: BlocBuilder<DocumentsCubit, DocumentsState>(
|
||||
builder: (context, state) {
|
||||
return ColoredBox(
|
||||
color: Theme.of(context).colorScheme.background,
|
||||
child: SavedViewSelectionWidget(
|
||||
height: _savedViewWidgetHeight,
|
||||
currentFilter: state.filter,
|
||||
enabled: state.selection.isEmpty &&
|
||||
connectivityState.isConnected,
|
||||
),
|
||||
);
|
||||
},
|
||||
child: NestedScrollView(
|
||||
headerSliverBuilder: (context, innerBoxIsScrolled) => [
|
||||
SliverOverlapAbsorber(
|
||||
// This widget takes the overlapping behavior of the SliverAppBar,
|
||||
// and redirects it to the SliverOverlapInjector below. If it is
|
||||
// missing, then it is possible for the nested "inner" scroll view
|
||||
// below to end up under the SliverAppBar even when the inner
|
||||
// scroll view thinks it has not been scrolled.
|
||||
// This is not necessary if the "headerSliverBuilder" only builds
|
||||
// widgets that do not overlap the next sliver.
|
||||
handle: NestedScrollView.sliverOverlapAbsorberHandleFor(
|
||||
context,
|
||||
),
|
||||
sliver: BlocBuilder<DocumentsCubit, DocumentsState>(
|
||||
builder: (context, state) {
|
||||
if (state.selection.isNotEmpty) {
|
||||
return SliverAppBar(
|
||||
floating: false,
|
||||
pinned: true,
|
||||
leading: IconButton(
|
||||
icon: const Icon(Icons.close),
|
||||
onPressed: () => context
|
||||
.read<DocumentsCubit>()
|
||||
.resetSelection(),
|
||||
),
|
||||
title: Text(
|
||||
"${state.selection.length} ${S.of(context).documentsSelectedText}",
|
||||
),
|
||||
actions: [
|
||||
IconButton(
|
||||
icon: const Icon(Icons.delete),
|
||||
onPressed: () => _onDelete(state),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
return SearchAppBar(
|
||||
hintText: S.of(context).documentSearchSearchDocuments,
|
||||
onOpenSearch: showDocumentSearchPage,
|
||||
bottom: TabBar(
|
||||
controller: _tabController,
|
||||
tabs: [
|
||||
Tab(text: S.of(context).documentsPageTitle),
|
||||
Tab(text: S.of(context).savedViewsLabel),
|
||||
],
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
],
|
||||
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: NotificationListener<ScrollMetricsNotification>(
|
||||
onNotification: (notification) {
|
||||
// Listen for scroll notifications to load new data.
|
||||
// Scroll controller does not work here due to nestedscrollview limitations.
|
||||
final currState = context.read<DocumentsCubit>().state;
|
||||
final max = notification.metrics.maxScrollExtent;
|
||||
if (max == 0 ||
|
||||
_currentTab != 0 ||
|
||||
currState.isLoading ||
|
||||
currState.isLastPageLoaded) {
|
||||
return true;
|
||||
}
|
||||
final offset = notification.metrics.pixels;
|
||||
if (offset >= max * 0.7) {
|
||||
context
|
||||
.read<DocumentsCubit>()
|
||||
.loadMore()
|
||||
.onError<PaperlessServerException>(
|
||||
(error, stackTrace) => showErrorMessage(
|
||||
context,
|
||||
error,
|
||||
stackTrace,
|
||||
),
|
||||
);
|
||||
}
|
||||
return false;
|
||||
},
|
||||
child: TabBarView(
|
||||
controller: _tabController,
|
||||
children: [
|
||||
Builder(
|
||||
builder: (context) {
|
||||
return RefreshIndicator(
|
||||
edgeOffset: kToolbarHeight + kTextTabBarHeight,
|
||||
onRefresh: _onReloadDocuments,
|
||||
notificationPredicate: (_) =>
|
||||
connectivityState.isConnected,
|
||||
child: CustomScrollView(
|
||||
key: const PageStorageKey<String>("documents"),
|
||||
slivers: <Widget>[
|
||||
SliverOverlapInjector(
|
||||
handle: NestedScrollView
|
||||
.sliverOverlapAbsorberHandleFor(
|
||||
context),
|
||||
),
|
||||
_buildViewActions(),
|
||||
BlocBuilder<DocumentsCubit, DocumentsState>(
|
||||
// Not required anymore since saved views are now handled separately
|
||||
// buildWhen: (previous, current) =>
|
||||
// !const ListEquality().equals(
|
||||
// previous.documents,
|
||||
// current.documents,
|
||||
// ) ||
|
||||
// previous.selectedIds !=
|
||||
// current.selectedIds,
|
||||
builder: (context, state) {
|
||||
if (state.hasLoaded &&
|
||||
state.documents.isEmpty) {
|
||||
return SliverToBoxAdapter(
|
||||
child: DocumentsEmptyState(
|
||||
state: state,
|
||||
onReset: () {
|
||||
context
|
||||
.read<DocumentsCubit>()
|
||||
.resetFilter();
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
return BlocBuilder<
|
||||
ApplicationSettingsCubit,
|
||||
ApplicationSettingsState>(
|
||||
builder: (context, settings) {
|
||||
return SliverAdaptiveDocumentsView(
|
||||
viewType:
|
||||
settings.preferredViewType,
|
||||
onTap: _openDetails,
|
||||
onSelected: context
|
||||
.read<DocumentsCubit>()
|
||||
.toggleDocumentSelection,
|
||||
hasInternetConnection:
|
||||
connectivityState.isConnected,
|
||||
onTagSelected: _addTagToFilter,
|
||||
onCorrespondentSelected:
|
||||
_addCorrespondentToFilter,
|
||||
onDocumentTypeSelected:
|
||||
_addDocumentTypeToFilter,
|
||||
onStoragePathSelected:
|
||||
_addStoragePathToFilter,
|
||||
documents: state.documents,
|
||||
hasLoaded: state.hasLoaded,
|
||||
isLabelClickable: true,
|
||||
isLoading: state.isLoading,
|
||||
selectedDocumentIds:
|
||||
state.selectedIds,
|
||||
);
|
||||
},
|
||||
);
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
Builder(
|
||||
builder: (context) {
|
||||
return RefreshIndicator(
|
||||
edgeOffset: kToolbarHeight + kTextTabBarHeight,
|
||||
onRefresh: _onReloadSavedViews,
|
||||
notificationPredicate: (_) =>
|
||||
connectivityState.isConnected,
|
||||
child: CustomScrollView(
|
||||
key: const PageStorageKey<String>("savedViews"),
|
||||
slivers: <Widget>[
|
||||
SliverOverlapInjector(
|
||||
handle: NestedScrollView
|
||||
.sliverOverlapAbsorberHandleFor(
|
||||
context),
|
||||
),
|
||||
const SavedViewList(),
|
||||
],
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
@@ -265,7 +354,33 @@ class _DocumentsPageState extends State<DocumentsPage> {
|
||||
);
|
||||
}
|
||||
|
||||
void _onDelete(BuildContext context, DocumentsState documentsState) async {
|
||||
Widget _buildViewActions() {
|
||||
return SliverToBoxAdapter(
|
||||
child: Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
const SortDocumentsButton(),
|
||||
BlocBuilder<ApplicationSettingsCubit, ApplicationSettingsState>(
|
||||
builder: (context, state) {
|
||||
return IconButton(
|
||||
icon: Icon(
|
||||
state.preferredViewType == ViewType.list
|
||||
? Icons.grid_view_rounded
|
||||
: Icons.list,
|
||||
),
|
||||
onPressed: () =>
|
||||
context.read<ApplicationSettingsCubit>().setViewType(
|
||||
state.preferredViewType.toggle(),
|
||||
),
|
||||
);
|
||||
},
|
||||
)
|
||||
],
|
||||
).paddedSymmetrically(horizontal: 8, vertical: 4),
|
||||
);
|
||||
}
|
||||
|
||||
void _onDelete(DocumentsState documentsState) async {
|
||||
final shouldDelete = await showDialog<bool>(
|
||||
context: context,
|
||||
builder: (context) =>
|
||||
@@ -276,7 +391,7 @@ class _DocumentsPageState extends State<DocumentsPage> {
|
||||
try {
|
||||
await context
|
||||
.read<DocumentsCubit>()
|
||||
.bulkRemove(documentsState.selection);
|
||||
.bulkDelete(documentsState.selection);
|
||||
showSnackBar(
|
||||
context,
|
||||
S.of(context).documentsPageBulkDeleteSuccessfulText,
|
||||
@@ -288,6 +403,25 @@ class _DocumentsPageState extends State<DocumentsPage> {
|
||||
}
|
||||
}
|
||||
|
||||
void _onCreateSavedView(DocumentFilter filter) async {
|
||||
final newView = await Navigator.of(context).push<SavedView?>(
|
||||
MaterialPageRoute(
|
||||
builder: (context) => LabelsBlocProvider(
|
||||
child: AddSavedViewPage(
|
||||
currentFilter: filter,
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
if (newView != null) {
|
||||
try {
|
||||
await context.read<SavedViewCubit>().add(newView);
|
||||
} on PaperlessServerException catch (error, stackTrace) {
|
||||
showErrorMessage(context, error, stackTrace);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
void _openDocumentFilter() async {
|
||||
final draggableSheetController = DraggableScrollableController();
|
||||
final filterIntent = await showModalBottomSheet<DocumentFilterIntent>(
|
||||
@@ -323,12 +457,7 @@ class _DocumentsPageState extends State<DocumentsPage> {
|
||||
try {
|
||||
if (filterIntent.shouldReset) {
|
||||
await context.read<DocumentsCubit>().resetFilter();
|
||||
context.read<DocumentsCubit>().unselectView();
|
||||
} else {
|
||||
if (filterIntent.filter !=
|
||||
context.read<DocumentsCubit>().state.filter) {
|
||||
context.read<DocumentsCubit>().unselectView();
|
||||
}
|
||||
await context
|
||||
.read<DocumentsCubit>()
|
||||
.updateFilter(filter: filterIntent.filter!);
|
||||
@@ -339,73 +468,12 @@ class _DocumentsPageState extends State<DocumentsPage> {
|
||||
}
|
||||
}
|
||||
|
||||
String _formatDocumentCount(int count) {
|
||||
return count > 99 ? "99+" : count.toString();
|
||||
}
|
||||
|
||||
Widget _buildBody(ConnectivityState connectivityState) {
|
||||
final isConnected = connectivityState == ConnectivityState.connected;
|
||||
return BlocBuilder<ApplicationSettingsCubit, ApplicationSettingsState>(
|
||||
builder: (context, settings) {
|
||||
return BlocBuilder<DocumentsCubit, DocumentsState>(
|
||||
buildWhen: (previous, current) =>
|
||||
!const ListEquality()
|
||||
.equals(previous.documents, current.documents) ||
|
||||
previous.selectedIds != current.selectedIds,
|
||||
builder: (context, state) {
|
||||
// Some ugly tricks to make it work with bloc, update pageController
|
||||
|
||||
if (state.hasLoaded && state.documents.isEmpty) {
|
||||
return DocumentsEmptyState(
|
||||
state: state,
|
||||
onReset: () {
|
||||
context.read<DocumentsCubit>().resetFilter();
|
||||
context.read<DocumentsCubit>().unselectView();
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
return AdaptiveDocumentsView(
|
||||
viewType: settings.preferredViewType,
|
||||
state: state,
|
||||
scrollController: _scrollController,
|
||||
onTap: _openDetails,
|
||||
onSelected: _onSelected,
|
||||
hasInternetConnection: isConnected,
|
||||
onTagSelected: _addTagToFilter,
|
||||
onCorrespondentSelected: _addCorrespondentToFilter,
|
||||
onDocumentTypeSelected: _addDocumentTypeToFilter,
|
||||
onStoragePathSelected: _addStoragePathToFilter,
|
||||
pageLoadingWidget: const NewItemsLoadingWidget(),
|
||||
beforeItems: const SizedBox(height: _savedViewWidgetHeight),
|
||||
);
|
||||
},
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _openDetails(DocumentModel document) async {
|
||||
final potentiallyUpdatedModel =
|
||||
await Navigator.of(context).push<DocumentModel?>(
|
||||
_buildDetailsPageRoute(document),
|
||||
);
|
||||
if (potentiallyUpdatedModel != document) {
|
||||
context.read<DocumentsCubit>().reload();
|
||||
}
|
||||
}
|
||||
|
||||
MaterialPageRoute<DocumentModel?> _buildDetailsPageRoute(
|
||||
DocumentModel document) {
|
||||
return MaterialPageRoute(
|
||||
builder: (_) => BlocProvider(
|
||||
create: (context) => DocumentDetailsCubit(
|
||||
context.read<PaperlessDocumentsApi>(),
|
||||
document,
|
||||
),
|
||||
child: const LabelRepositoriesProvider(
|
||||
child: DocumentDetailsPage(),
|
||||
),
|
||||
void _openDetails(DocumentModel document) {
|
||||
Navigator.pushNamed(
|
||||
context,
|
||||
DocumentDetailsRoute.routeName,
|
||||
arguments: DocumentDetailsRouteArguments(
|
||||
document: document,
|
||||
),
|
||||
);
|
||||
}
|
||||
@@ -491,23 +559,19 @@ class _DocumentsPageState extends State<DocumentsPage> {
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _loadNewPage() async {
|
||||
Future<void> _onReloadDocuments() async {
|
||||
try {
|
||||
await context.read<DocumentsCubit>().loadMore();
|
||||
// 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 PaperlessServerException catch (error, stackTrace) {
|
||||
showErrorMessage(context, error, stackTrace);
|
||||
}
|
||||
}
|
||||
|
||||
void _onSelected(DocumentModel model) {
|
||||
context.read<DocumentsCubit>().toggleDocumentSelection(model);
|
||||
}
|
||||
|
||||
Future<void> _onRefresh() async {
|
||||
Future<void> _onReloadSavedViews() async {
|
||||
try {
|
||||
// We do not await here on purpose so we can show a linear progress indicator below the app bar.
|
||||
context.read<DocumentsCubit>().reload();
|
||||
context.read<SavedViewCubit>().reload();
|
||||
await context.read<SavedViewCubit>().reload();
|
||||
} on PaperlessServerException catch (error, stackTrace) {
|
||||
showErrorMessage(context, error, stackTrace);
|
||||
}
|
||||
|
||||
229
lib/features/documents/view/widgets/adaptive_documents_view.dart
Normal file
229
lib/features/documents/view/widgets/adaptive_documents_view.dart
Normal file
@@ -0,0 +1,229 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:paperless_api/paperless_api.dart';
|
||||
import 'package:paperless_mobile/core/repository/provider/label_repositories_provider.dart';
|
||||
import 'package:paperless_mobile/features/documents/view/widgets/document_grid_loading_widget.dart';
|
||||
import 'package:paperless_mobile/features/documents/view/widgets/documents_list_loading_widget.dart';
|
||||
import 'package:paperless_mobile/features/documents/view/widgets/items/document_grid_item.dart';
|
||||
import 'package:paperless_mobile/features/documents/view/widgets/items/document_list_item.dart';
|
||||
import 'package:paperless_mobile/features/settings/model/view_type.dart';
|
||||
|
||||
abstract class AdaptiveDocumentsView extends StatelessWidget {
|
||||
final List<DocumentModel> documents;
|
||||
final bool isLoading;
|
||||
final bool hasLoaded;
|
||||
final bool enableHeroAnimation;
|
||||
final List<int> selectedDocumentIds;
|
||||
final ViewType viewType;
|
||||
final void Function(DocumentModel)? onTap;
|
||||
final void Function(DocumentModel)? onSelected;
|
||||
final bool hasInternetConnection;
|
||||
final bool isLabelClickable;
|
||||
final void Function(int id)? onTagSelected;
|
||||
final void Function(int? id)? onCorrespondentSelected;
|
||||
final void Function(int? id)? onDocumentTypeSelected;
|
||||
final void Function(int? id)? onStoragePathSelected;
|
||||
|
||||
bool get showLoadingPlaceholder => (!hasLoaded && isLoading);
|
||||
const AdaptiveDocumentsView({
|
||||
super.key,
|
||||
this.selectedDocumentIds = const [],
|
||||
required this.documents,
|
||||
this.onTap,
|
||||
this.onSelected,
|
||||
this.viewType = ViewType.list,
|
||||
required this.hasInternetConnection,
|
||||
required this.isLabelClickable,
|
||||
this.onTagSelected,
|
||||
this.onCorrespondentSelected,
|
||||
this.onDocumentTypeSelected,
|
||||
this.onStoragePathSelected,
|
||||
required this.isLoading,
|
||||
required this.hasLoaded,
|
||||
this.enableHeroAnimation = true,
|
||||
});
|
||||
}
|
||||
|
||||
class SliverAdaptiveDocumentsView extends AdaptiveDocumentsView {
|
||||
const SliverAdaptiveDocumentsView({
|
||||
super.key,
|
||||
required super.documents,
|
||||
required super.hasInternetConnection,
|
||||
required super.isLabelClickable,
|
||||
super.onCorrespondentSelected,
|
||||
super.onDocumentTypeSelected,
|
||||
super.onStoragePathSelected,
|
||||
super.onSelected,
|
||||
super.onTagSelected,
|
||||
super.onTap,
|
||||
super.selectedDocumentIds,
|
||||
super.viewType,
|
||||
super.enableHeroAnimation,
|
||||
required super.isLoading,
|
||||
required super.hasLoaded,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
switch (viewType) {
|
||||
case ViewType.grid:
|
||||
return _buildGridView();
|
||||
case ViewType.list:
|
||||
return _buildListView();
|
||||
}
|
||||
}
|
||||
|
||||
Widget _buildListView() {
|
||||
if (showLoadingPlaceholder) {
|
||||
return DocumentsListLoadingWidget.sliver();
|
||||
}
|
||||
return SliverList(
|
||||
delegate: SliverChildBuilderDelegate(
|
||||
childCount: documents.length,
|
||||
(context, index) {
|
||||
final document = documents.elementAt(index);
|
||||
return LabelRepositoriesProvider(
|
||||
child: DocumentListItem(
|
||||
isLabelClickable: isLabelClickable,
|
||||
document: document,
|
||||
onTap: onTap,
|
||||
isSelected: selectedDocumentIds.contains(document.id),
|
||||
onSelected: onSelected,
|
||||
isSelectionActive: selectedDocumentIds.isNotEmpty,
|
||||
onTagSelected: onTagSelected,
|
||||
onCorrespondentSelected: onCorrespondentSelected,
|
||||
onDocumentTypeSelected: onDocumentTypeSelected,
|
||||
onStoragePathSelected: onStoragePathSelected,
|
||||
enableHeroAnimation: enableHeroAnimation,
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildGridView() {
|
||||
if (showLoadingPlaceholder) {
|
||||
return DocumentGridLoadingWidget.sliver();
|
||||
}
|
||||
return SliverGrid.builder(
|
||||
gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount(
|
||||
crossAxisCount: 2,
|
||||
mainAxisSpacing: 4,
|
||||
crossAxisSpacing: 4,
|
||||
childAspectRatio: 1 / 2,
|
||||
),
|
||||
itemCount: documents.length,
|
||||
itemBuilder: (context, index) {
|
||||
final document = documents.elementAt(index);
|
||||
return DocumentGridItem(
|
||||
document: document,
|
||||
onTap: onTap,
|
||||
isSelected: selectedDocumentIds.contains(document.id),
|
||||
onSelected: onSelected,
|
||||
isSelectionActive: selectedDocumentIds.isNotEmpty,
|
||||
isLabelClickable: isLabelClickable,
|
||||
onTagSelected: onTagSelected,
|
||||
onCorrespondentSelected: onCorrespondentSelected,
|
||||
onDocumentTypeSelected: onDocumentTypeSelected,
|
||||
onStoragePathSelected: onStoragePathSelected,
|
||||
enableHeroAnimation: enableHeroAnimation,
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class DefaultAdaptiveDocumentsView extends AdaptiveDocumentsView {
|
||||
final ScrollController? scrollController;
|
||||
const DefaultAdaptiveDocumentsView({
|
||||
super.key,
|
||||
required super.documents,
|
||||
required super.hasInternetConnection,
|
||||
required super.isLabelClickable,
|
||||
required super.isLoading,
|
||||
required super.hasLoaded,
|
||||
super.onCorrespondentSelected,
|
||||
super.onDocumentTypeSelected,
|
||||
super.onStoragePathSelected,
|
||||
super.onSelected,
|
||||
super.onTagSelected,
|
||||
super.onTap,
|
||||
this.scrollController,
|
||||
super.selectedDocumentIds,
|
||||
super.viewType,
|
||||
super.enableHeroAnimation = true,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
switch (viewType) {
|
||||
case ViewType.grid:
|
||||
return _buildGridView();
|
||||
case ViewType.list:
|
||||
return _buildListView();
|
||||
}
|
||||
}
|
||||
|
||||
Widget _buildListView() {
|
||||
if (showLoadingPlaceholder) {
|
||||
return DocumentsListLoadingWidget();
|
||||
}
|
||||
|
||||
return ListView.builder(
|
||||
controller: scrollController,
|
||||
primary: false,
|
||||
itemCount: documents.length,
|
||||
itemBuilder: (context, index) {
|
||||
final document = documents.elementAt(index);
|
||||
return LabelRepositoriesProvider(
|
||||
child: DocumentListItem(
|
||||
isLabelClickable: isLabelClickable,
|
||||
document: document,
|
||||
onTap: onTap,
|
||||
isSelected: selectedDocumentIds.contains(document.id),
|
||||
onSelected: onSelected,
|
||||
isSelectionActive: selectedDocumentIds.isNotEmpty,
|
||||
onTagSelected: onTagSelected,
|
||||
onCorrespondentSelected: onCorrespondentSelected,
|
||||
onDocumentTypeSelected: onDocumentTypeSelected,
|
||||
onStoragePathSelected: onStoragePathSelected,
|
||||
enableHeroAnimation: enableHeroAnimation,
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildGridView() {
|
||||
if (showLoadingPlaceholder) {
|
||||
return DocumentGridLoadingWidget();
|
||||
}
|
||||
return GridView.builder(
|
||||
controller: scrollController,
|
||||
primary: false,
|
||||
gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount(
|
||||
crossAxisCount: 2,
|
||||
mainAxisSpacing: 4,
|
||||
crossAxisSpacing: 4,
|
||||
childAspectRatio: 1 / 2,
|
||||
),
|
||||
itemCount: documents.length,
|
||||
itemBuilder: (context, index) {
|
||||
final document = documents.elementAt(index);
|
||||
return DocumentGridItem(
|
||||
document: document,
|
||||
onTap: onTap,
|
||||
isSelected: selectedDocumentIds.contains(document.id),
|
||||
onSelected: onSelected,
|
||||
isSelectionActive: selectedDocumentIds.isNotEmpty,
|
||||
isLabelClickable: isLabelClickable,
|
||||
onTagSelected: onTagSelected,
|
||||
onCorrespondentSelected: onCorrespondentSelected,
|
||||
onDocumentTypeSelected: onDocumentTypeSelected,
|
||||
onStoragePathSelected: onStoragePathSelected,
|
||||
enableHeroAnimation: enableHeroAnimation,
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,102 @@
|
||||
import 'dart:math';
|
||||
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/widgets.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/document_preview.dart';
|
||||
import 'package:paperless_mobile/features/documents/view/widgets/placeholder/document_item_placeholder.dart';
|
||||
import 'package:paperless_mobile/features/documents/view/widgets/placeholder/tags_placeholder.dart';
|
||||
import 'package:paperless_mobile/features/documents/view/widgets/placeholder/text_placeholder.dart';
|
||||
import 'package:shimmer/shimmer.dart';
|
||||
|
||||
class DocumentGridLoadingWidget extends StatelessWidget
|
||||
with DocumentItemPlaceholder {
|
||||
final bool _isSliver;
|
||||
@override
|
||||
final Random random = Random(1257195195);
|
||||
DocumentGridLoadingWidget({super.key}) : _isSliver = false;
|
||||
|
||||
DocumentGridLoadingWidget.sliver({super.key}) : _isSliver = true;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
const delegate = SliverGridDelegateWithFixedCrossAxisCount(
|
||||
crossAxisCount: 2,
|
||||
mainAxisSpacing: 4,
|
||||
crossAxisSpacing: 4,
|
||||
childAspectRatio: 1 / 2,
|
||||
);
|
||||
if (_isSliver) {
|
||||
return SliverGrid.builder(
|
||||
gridDelegate: delegate,
|
||||
itemBuilder: (context, index) => _buildPlaceholderGridItem(context),
|
||||
);
|
||||
}
|
||||
return GridView.builder(
|
||||
gridDelegate: delegate,
|
||||
itemBuilder: (context, index) => _buildPlaceholderGridItem(context),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildPlaceholderGridItem(BuildContext context) {
|
||||
final values = nextValues;
|
||||
|
||||
return Padding(
|
||||
padding: const EdgeInsets.all(8.0),
|
||||
child: Card(
|
||||
elevation: 1.0,
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
ShimmerPlaceholder(
|
||||
child: AspectRatio(
|
||||
aspectRatio: 1,
|
||||
child: ClipRRect(
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
child: Container(
|
||||
color: Colors.white,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
Expanded(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(8.0),
|
||||
child: ShimmerPlaceholder(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
TextPlaceholder(
|
||||
length: values.correspondentLength,
|
||||
fontSize: 16,
|
||||
).padded(1),
|
||||
TextPlaceholder(
|
||||
length: values.titleLength,
|
||||
fontSize: 16,
|
||||
),
|
||||
if (values.tagCount > 0) ...[
|
||||
const Spacer(),
|
||||
TagsPlaceholder(
|
||||
count: values.tagCount,
|
||||
dense: true,
|
||||
),
|
||||
],
|
||||
const Spacer(),
|
||||
TextPlaceholder(
|
||||
length: 100,
|
||||
fontSize:
|
||||
Theme.of(context).textTheme.bodySmall!.fontSize!,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -2,17 +2,16 @@ import 'package:flutter/material.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/documents/bloc/documents_cubit.dart';
|
||||
import 'package:paperless_mobile/features/documents/bloc/documents_state.dart';
|
||||
import 'package:paperless_mobile/features/paged_document_view/model/paged_documents_state.dart';
|
||||
import 'package:paperless_mobile/generated/l10n.dart';
|
||||
|
||||
class DocumentsEmptyState extends StatelessWidget {
|
||||
final DocumentsState state;
|
||||
final VoidCallback onReset;
|
||||
final PagedDocumentsState state;
|
||||
final VoidCallback? onReset;
|
||||
const DocumentsEmptyState({
|
||||
Key? key,
|
||||
required this.state,
|
||||
required this.onReset,
|
||||
this.onReset,
|
||||
}) : super(key: key);
|
||||
|
||||
@override
|
||||
@@ -21,7 +20,7 @@ class DocumentsEmptyState extends StatelessWidget {
|
||||
child: EmptyState(
|
||||
title: S.of(context).documentsPageEmptyStateOopsText,
|
||||
subtitle: S.of(context).documentsPageEmptyStateNothingHereText,
|
||||
bottomChild: state.filter != DocumentFilter.initial
|
||||
bottomChild: state.filter != DocumentFilter.initial && onReset != null
|
||||
? TextButton(
|
||||
onPressed: onReset,
|
||||
child: Text(
|
||||
|
||||
@@ -0,0 +1,82 @@
|
||||
import 'dart:math';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:paperless_mobile/core/widgets/shimmer_placeholder.dart';
|
||||
import 'package:paperless_mobile/extensions/flutter_extensions.dart';
|
||||
import 'package:paperless_mobile/features/documents/view/widgets/placeholder/document_item_placeholder.dart';
|
||||
import 'package:paperless_mobile/features/documents/view/widgets/placeholder/tags_placeholder.dart';
|
||||
import 'package:paperless_mobile/features/documents/view/widgets/placeholder/text_placeholder.dart';
|
||||
|
||||
class DocumentsListLoadingWidget extends StatelessWidget
|
||||
with DocumentItemPlaceholder {
|
||||
final bool _isSliver;
|
||||
DocumentsListLoadingWidget({super.key}) : _isSliver = false;
|
||||
|
||||
DocumentsListLoadingWidget.sliver({super.key}) : _isSliver = true;
|
||||
|
||||
@override
|
||||
final Random random = Random(1209571050);
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
if (_isSliver) {
|
||||
return SliverList(
|
||||
delegate: SliverChildBuilderDelegate(
|
||||
(context, index) => _buildFakeListItem(context),
|
||||
),
|
||||
);
|
||||
} else {
|
||||
return ListView.builder(
|
||||
physics: const NeverScrollableScrollPhysics(),
|
||||
itemBuilder: (context, index) => _buildFakeListItem(context),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Widget _buildFakeListItem(BuildContext context) {
|
||||
const fontSize = 14.0;
|
||||
final values = nextValues;
|
||||
return ShimmerPlaceholder(
|
||||
child: ListTile(
|
||||
contentPadding: const EdgeInsets.all(8),
|
||||
dense: true,
|
||||
isThreeLine: true,
|
||||
leading: ClipRRect(
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
child: Container(
|
||||
color: Colors.white,
|
||||
height: double.infinity,
|
||||
width: 35,
|
||||
),
|
||||
),
|
||||
title: Row(
|
||||
children: [
|
||||
TextPlaceholder(
|
||||
length: values.correspondentLength,
|
||||
fontSize: fontSize,
|
||||
),
|
||||
],
|
||||
),
|
||||
subtitle: Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: 2.0),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
mainAxisAlignment: MainAxisAlignment.spaceAround,
|
||||
children: [
|
||||
TextPlaceholder(
|
||||
length: values.titleLength,
|
||||
fontSize: fontSize,
|
||||
),
|
||||
if (values.tagCount > 0)
|
||||
TagsPlaceholder(count: values.tagCount, dense: true),
|
||||
TextPlaceholder(
|
||||
length: 100,
|
||||
fontSize: Theme.of(context).textTheme.labelSmall!.fontSize!,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,38 +1,35 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:paperless_api/paperless_api.dart';
|
||||
import 'package:paperless_mobile/features/documents/view/widgets/document_preview.dart';
|
||||
import 'package:paperless_mobile/features/documents/view/widgets/items/document_item.dart';
|
||||
import 'package:paperless_mobile/features/labels/correspondent/view/widgets/correspondent_widget.dart';
|
||||
import 'package:paperless_mobile/features/labels/document_type/view/widgets/document_type_widget.dart';
|
||||
import 'package:paperless_mobile/features/labels/tags/view/widgets/tags_widget.dart';
|
||||
import 'package:intl/intl.dart';
|
||||
|
||||
class DocumentGridItem extends StatelessWidget {
|
||||
final DocumentModel document;
|
||||
final bool isSelected;
|
||||
final void Function(DocumentModel) onTap;
|
||||
final void Function(DocumentModel) onSelected;
|
||||
final bool isAtLeastOneSelected;
|
||||
final bool Function(int tagId) isTagSelectedPredicate;
|
||||
final void Function(int tagId)? onTagSelected;
|
||||
|
||||
class DocumentGridItem extends DocumentItem {
|
||||
const DocumentGridItem({
|
||||
Key? key,
|
||||
required this.document,
|
||||
required this.onTap,
|
||||
required this.onSelected,
|
||||
required this.isSelected,
|
||||
required this.isAtLeastOneSelected,
|
||||
required this.isTagSelectedPredicate,
|
||||
required this.onTagSelected,
|
||||
}) : super(key: key);
|
||||
super.key,
|
||||
required super.document,
|
||||
required super.isSelected,
|
||||
required super.isSelectionActive,
|
||||
required super.isLabelClickable,
|
||||
super.onCorrespondentSelected,
|
||||
super.onDocumentTypeSelected,
|
||||
super.onSelected,
|
||||
super.onStoragePathSelected,
|
||||
super.onTagSelected,
|
||||
super.onTap,
|
||||
required super.enableHeroAnimation,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return GestureDetector(
|
||||
onTap: _onTap,
|
||||
onLongPress: () => onSelected(document),
|
||||
onLongPress: onSelected != null ? () => onSelected!(document) : null,
|
||||
child: AbsorbPointer(
|
||||
absorbing: isAtLeastOneSelected,
|
||||
absorbing: isSelectionActive,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(8.0),
|
||||
child: Card(
|
||||
@@ -48,6 +45,7 @@ class DocumentGridItem extends StatelessWidget {
|
||||
child: DocumentPreview(
|
||||
id: document.id,
|
||||
borderRadius: 12.0,
|
||||
enableHero: enableHeroAnimation,
|
||||
),
|
||||
),
|
||||
Expanded(
|
||||
@@ -94,10 +92,10 @@ class DocumentGridItem extends StatelessWidget {
|
||||
}
|
||||
|
||||
void _onTap() {
|
||||
if (isAtLeastOneSelected || isSelected) {
|
||||
onSelected(document);
|
||||
if (isSelectionActive || isSelected) {
|
||||
onSelected?.call(document);
|
||||
} else {
|
||||
onTap(document);
|
||||
onTap?.call(document);
|
||||
}
|
||||
}
|
||||
}
|
||||
32
lib/features/documents/view/widgets/items/document_item.dart
Normal file
32
lib/features/documents/view/widgets/items/document_item.dart
Normal file
@@ -0,0 +1,32 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:paperless_api/paperless_api.dart';
|
||||
|
||||
abstract class DocumentItem extends StatelessWidget {
|
||||
final DocumentModel document;
|
||||
final void Function(DocumentModel)? onTap;
|
||||
final void Function(DocumentModel)? onSelected;
|
||||
final bool isSelected;
|
||||
final bool isSelectionActive;
|
||||
final bool isLabelClickable;
|
||||
final bool enableHeroAnimation;
|
||||
|
||||
final void Function(int tagId)? onTagSelected;
|
||||
final void Function(int? correspondentId)? onCorrespondentSelected;
|
||||
final void Function(int? documentTypeId)? onDocumentTypeSelected;
|
||||
final void Function(int? id)? onStoragePathSelected;
|
||||
|
||||
const DocumentItem({
|
||||
super.key,
|
||||
required this.document,
|
||||
this.onTap,
|
||||
this.onSelected,
|
||||
required this.isSelected,
|
||||
required this.isSelectionActive,
|
||||
required this.isLabelClickable,
|
||||
this.onTagSelected,
|
||||
this.onCorrespondentSelected,
|
||||
this.onDocumentTypeSelected,
|
||||
this.onStoragePathSelected,
|
||||
required this.enableHeroAnimation,
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,145 @@
|
||||
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/features/documents/view/widgets/document_preview.dart';
|
||||
import 'package:paperless_mobile/features/documents/view/widgets/items/document_item.dart';
|
||||
import 'package:paperless_mobile/features/labels/bloc/label_cubit.dart';
|
||||
import 'package:paperless_mobile/features/labels/bloc/label_state.dart';
|
||||
import 'package:paperless_mobile/features/labels/bloc/providers/document_type_bloc_provider.dart';
|
||||
import 'package:paperless_mobile/features/labels/correspondent/view/widgets/correspondent_widget.dart';
|
||||
import 'package:paperless_mobile/features/labels/tags/view/widgets/tags_widget.dart';
|
||||
|
||||
class DocumentListItem extends DocumentItem {
|
||||
static const _a4AspectRatio = 1 / 1.4142;
|
||||
|
||||
const DocumentListItem({
|
||||
super.key,
|
||||
required super.document,
|
||||
required super.isSelected,
|
||||
required super.isSelectionActive,
|
||||
required super.isLabelClickable,
|
||||
super.onCorrespondentSelected,
|
||||
super.onDocumentTypeSelected,
|
||||
super.onSelected,
|
||||
super.onStoragePathSelected,
|
||||
super.onTagSelected,
|
||||
super.onTap,
|
||||
super.enableHeroAnimation = true,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return DocumentTypeBlocProvider(
|
||||
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,
|
||||
correspondentId: document.correspondent,
|
||||
onSelected: onCorrespondentSelected,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
Text(
|
||||
document.title,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
maxLines: 1,
|
||||
),
|
||||
AbsorbPointer(
|
||||
absorbing: isSelectionActive,
|
||||
child: TagsWidget(
|
||||
isClickable: isLabelClickable,
|
||||
tagIds: document.tags,
|
||||
isMultiLine: false,
|
||||
onTagSelected: (id) => onTagSelected?.call(id),
|
||||
),
|
||||
)
|
||||
],
|
||||
),
|
||||
subtitle: Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: 4),
|
||||
child:
|
||||
BlocBuilder<LabelCubit<DocumentType>, LabelState<DocumentType>>(
|
||||
builder: (context, docTypes) {
|
||||
return 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:
|
||||
docTypes.labels[document.documentType]?.name,
|
||||
),
|
||||
]
|
||||
: null,
|
||||
),
|
||||
);
|
||||
},
|
||||
)
|
||||
// Row(
|
||||
// children: [
|
||||
// Text(
|
||||
// DateFormat.yMMMd().format(document.created),
|
||||
// style: Theme.of(context)
|
||||
// .textTheme
|
||||
// .bodySmall
|
||||
// ?.apply(color: Colors.grey),
|
||||
// ),
|
||||
// if (document.documentType != null) ...[
|
||||
// Text("\u30FB"),
|
||||
// DocumentTypeWidget(
|
||||
// documentTypeId: document.documentType,
|
||||
// textStyle: Theme.of(context).textTheme.bodySmall?.apply(
|
||||
// color: Colors.grey,
|
||||
// overflow: TextOverflow.ellipsis,
|
||||
// ),
|
||||
// ),
|
||||
// ],
|
||||
// ],
|
||||
// ),
|
||||
),
|
||||
isThreeLine: document.tags.isNotEmpty,
|
||||
leading: AspectRatio(
|
||||
aspectRatio: _a4AspectRatio,
|
||||
child: GestureDetector(
|
||||
child: DocumentPreview(
|
||||
id: document.id,
|
||||
fit: BoxFit.cover,
|
||||
alignment: Alignment.topCenter,
|
||||
enableHero: enableHeroAnimation,
|
||||
),
|
||||
),
|
||||
),
|
||||
contentPadding: const EdgeInsets.all(8.0),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
void _onTap() {
|
||||
if (isSelectionActive || isSelected) {
|
||||
onSelected?.call(document);
|
||||
} else {
|
||||
onTap?.call(document);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,119 +0,0 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:paperless_api/paperless_api.dart';
|
||||
import 'package:paperless_mobile/core/repository/provider/label_repositories_provider.dart';
|
||||
import 'package:paperless_mobile/features/documents/bloc/documents_state.dart';
|
||||
import 'package:paperless_mobile/features/documents/view/widgets/grid/document_grid_item.dart';
|
||||
import 'package:paperless_mobile/features/documents/view/widgets/list/document_list_item.dart';
|
||||
import 'package:paperless_mobile/features/settings/model/view_type.dart';
|
||||
|
||||
class AdaptiveDocumentsView extends StatelessWidget {
|
||||
final ViewType viewType;
|
||||
final Widget beforeItems;
|
||||
final void Function(DocumentModel) onTap;
|
||||
final void Function(DocumentModel) onSelected;
|
||||
final ScrollController scrollController;
|
||||
final DocumentsState state;
|
||||
final bool hasInternetConnection;
|
||||
final bool isLabelClickable;
|
||||
final void Function(int id)? onTagSelected;
|
||||
final void Function(int? id)? onCorrespondentSelected;
|
||||
final void Function(int? id)? onDocumentTypeSelected;
|
||||
final void Function(int? id)? onStoragePathSelected;
|
||||
final Widget pageLoadingWidget;
|
||||
|
||||
const AdaptiveDocumentsView({
|
||||
super.key,
|
||||
required this.onTap,
|
||||
required this.scrollController,
|
||||
required this.state,
|
||||
required this.onSelected,
|
||||
required this.hasInternetConnection,
|
||||
this.isLabelClickable = true,
|
||||
this.onTagSelected,
|
||||
this.onCorrespondentSelected,
|
||||
this.onDocumentTypeSelected,
|
||||
this.onStoragePathSelected,
|
||||
required this.pageLoadingWidget,
|
||||
required this.beforeItems,
|
||||
required this.viewType,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return CustomScrollView(
|
||||
controller: scrollController,
|
||||
physics: const AlwaysScrollableScrollPhysics(),
|
||||
slivers: [
|
||||
SliverToBoxAdapter(child: beforeItems),
|
||||
if (viewType == ViewType.list) _buildListView() else _buildGridView(),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
SliverList _buildListView() {
|
||||
return SliverList(
|
||||
delegate: SliverChildBuilderDelegate(
|
||||
childCount: state.documents.length,
|
||||
(context, index) {
|
||||
final document = state.documents.elementAt(index);
|
||||
return LabelRepositoriesProvider(
|
||||
child: DocumentListItem(
|
||||
isLabelClickable: isLabelClickable,
|
||||
document: document,
|
||||
onTap: onTap,
|
||||
isSelected: state.selectedIds.contains(document.id),
|
||||
onSelected: onSelected,
|
||||
isAtLeastOneSelected: state.selection.isNotEmpty,
|
||||
isTagSelectedPredicate: (int tagId) {
|
||||
return state.filter.tags is IdsTagsQuery
|
||||
? (state.filter.tags as IdsTagsQuery)
|
||||
.includedIds
|
||||
.contains(tagId)
|
||||
: false;
|
||||
},
|
||||
onTagSelected: onTagSelected,
|
||||
onCorrespondentSelected: onCorrespondentSelected,
|
||||
onDocumentTypeSelected: onDocumentTypeSelected,
|
||||
onStoragePathSelected: onStoragePathSelected,
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildGridView() {
|
||||
return SliverGrid.builder(
|
||||
gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount(
|
||||
crossAxisCount: 2,
|
||||
mainAxisSpacing: 4,
|
||||
crossAxisSpacing: 4,
|
||||
childAspectRatio: 1 / 2,
|
||||
),
|
||||
itemCount: state.documents.length,
|
||||
itemBuilder: (context, index) {
|
||||
if (state.hasLoaded &&
|
||||
state.isLoading &&
|
||||
index == state.documents.length) {
|
||||
return Center(child: pageLoadingWidget);
|
||||
}
|
||||
final document = state.documents.elementAt(index);
|
||||
return DocumentGridItem(
|
||||
document: document,
|
||||
onTap: onTap,
|
||||
isSelected: state.selectedIds.contains(document.id),
|
||||
onSelected: onSelected,
|
||||
isAtLeastOneSelected: state.selection.isNotEmpty,
|
||||
isTagSelectedPredicate: (int tagId) {
|
||||
return state.filter.tags is IdsTagsQuery
|
||||
? (state.filter.tags as IdsTagsQuery)
|
||||
.includedIds
|
||||
.contains(tagId)
|
||||
: false;
|
||||
},
|
||||
onTagSelected: onTagSelected,
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,102 +0,0 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:paperless_api/paperless_api.dart';
|
||||
import 'package:paperless_mobile/features/documents/view/widgets/document_preview.dart';
|
||||
import 'package:paperless_mobile/features/labels/correspondent/view/widgets/correspondent_widget.dart';
|
||||
import 'package:paperless_mobile/features/labels/tags/view/widgets/tags_widget.dart';
|
||||
|
||||
class DocumentListItem extends StatelessWidget {
|
||||
static const _a4AspectRatio = 1 / 1.4142;
|
||||
final DocumentModel document;
|
||||
final void Function(DocumentModel) onTap;
|
||||
final void Function(DocumentModel)? onSelected;
|
||||
final bool isSelected;
|
||||
final bool isAtLeastOneSelected;
|
||||
final bool isLabelClickable;
|
||||
final bool Function(int tagId) isTagSelectedPredicate;
|
||||
|
||||
final void Function(int tagId)? onTagSelected;
|
||||
final void Function(int? correspondentId)? onCorrespondentSelected;
|
||||
final void Function(int? documentTypeId)? onDocumentTypeSelected;
|
||||
final void Function(int? id)? onStoragePathSelected;
|
||||
|
||||
const DocumentListItem({
|
||||
Key? key,
|
||||
required this.document,
|
||||
required this.onTap,
|
||||
this.onSelected,
|
||||
required this.isSelected,
|
||||
required this.isAtLeastOneSelected,
|
||||
this.isLabelClickable = true,
|
||||
required this.isTagSelectedPredicate,
|
||||
this.onTagSelected,
|
||||
this.onCorrespondentSelected,
|
||||
this.onDocumentTypeSelected,
|
||||
this.onStoragePathSelected,
|
||||
}) : super(key: key);
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return 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: isAtLeastOneSelected,
|
||||
child: CorrespondentWidget(
|
||||
isClickable: isLabelClickable,
|
||||
correspondentId: document.correspondent,
|
||||
onSelected: onCorrespondentSelected,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
Text(
|
||||
document.title,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
maxLines: document.tags.isEmpty ? 2 : 1,
|
||||
),
|
||||
],
|
||||
),
|
||||
subtitle: Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: 4),
|
||||
child: AbsorbPointer(
|
||||
absorbing: isAtLeastOneSelected,
|
||||
child: TagsWidget(
|
||||
isClickable: isLabelClickable,
|
||||
tagIds: document.tags,
|
||||
isMultiLine: false,
|
||||
onTagSelected: (id) => onTagSelected?.call(id),
|
||||
),
|
||||
),
|
||||
),
|
||||
isThreeLine: document.tags.isNotEmpty,
|
||||
leading: AspectRatio(
|
||||
aspectRatio: _a4AspectRatio,
|
||||
child: GestureDetector(
|
||||
child: DocumentPreview(
|
||||
id: document.id,
|
||||
fit: BoxFit.cover,
|
||||
alignment: Alignment.topCenter,
|
||||
),
|
||||
),
|
||||
),
|
||||
contentPadding: const EdgeInsets.all(8.0),
|
||||
);
|
||||
}
|
||||
|
||||
void _onTap() {
|
||||
if (isAtLeastOneSelected || isSelected) {
|
||||
onSelected?.call(document);
|
||||
} else {
|
||||
onTap(document);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,10 +1,11 @@
|
||||
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 const CircularProgressIndicator();
|
||||
return Center(child: const CircularProgressIndicator().padded());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,21 +0,0 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_form_builder/flutter_form_builder.dart';
|
||||
import 'package:paperless_api/paperless_api.dart';
|
||||
|
||||
class OrderByDropdown extends StatefulWidget {
|
||||
static const fkOrderBy = "orderBy";
|
||||
const OrderByDropdown({super.key});
|
||||
|
||||
@override
|
||||
State<OrderByDropdown> createState() => _OrderByDropdownState();
|
||||
}
|
||||
|
||||
class _OrderByDropdownState extends State<OrderByDropdown> {
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return FormBuilderDropdown<SortField>(
|
||||
name: OrderByDropdown.fkOrderBy,
|
||||
items: const [],
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
import 'dart:math';
|
||||
|
||||
mixin DocumentItemPlaceholder {
|
||||
static const _tags = [" ", " ", " "];
|
||||
static const _titleLengths = <double>[double.infinity, 150.0, 200.0];
|
||||
static const _correspondentLengths = <double>[120.0, 80.0, 40.0];
|
||||
|
||||
Random get random;
|
||||
|
||||
RandomDocumentItemPlaceholderValues get nextValues {
|
||||
return RandomDocumentItemPlaceholderValues(
|
||||
tagCount: random.nextInt(_tags.length + 1),
|
||||
correspondentLength: _correspondentLengths[
|
||||
random.nextInt(_correspondentLengths.length - 1)],
|
||||
titleLength: _titleLengths[random.nextInt(_titleLengths.length - 1)],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class RandomDocumentItemPlaceholderValues {
|
||||
final int tagCount;
|
||||
final double correspondentLength;
|
||||
final double titleLength;
|
||||
|
||||
RandomDocumentItemPlaceholderValues({
|
||||
required this.tagCount,
|
||||
required this.correspondentLength,
|
||||
required this.titleLength,
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
class TagsPlaceholder extends StatelessWidget {
|
||||
static const _lengths = [24, 36, 16, 48];
|
||||
final int count;
|
||||
final bool dense;
|
||||
const TagsPlaceholder({
|
||||
super.key,
|
||||
required this.count,
|
||||
required this.dense,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return SizedBox(
|
||||
height: 32,
|
||||
child: ListView.separated(
|
||||
itemCount: count,
|
||||
scrollDirection: Axis.horizontal,
|
||||
itemBuilder: (context, index) => FilterChip(
|
||||
labelPadding:
|
||||
dense ? const EdgeInsets.symmetric(horizontal: 2) : null,
|
||||
padding: dense ? const EdgeInsets.all(4) : null,
|
||||
visualDensity: const VisualDensity(vertical: -2),
|
||||
materialTapTargetSize: MaterialTapTargetSize.shrinkWrap,
|
||||
side: BorderSide.none,
|
||||
onSelected: (_) {},
|
||||
selected: false,
|
||||
label: Text(
|
||||
List.filled(_lengths[index], " ").join(),
|
||||
),
|
||||
),
|
||||
separatorBuilder: (context, _) => const SizedBox(width: 4),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
import 'dart:math';
|
||||
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/widgets.dart';
|
||||
import 'package:paperless_mobile/extensions/flutter_extensions.dart';
|
||||
|
||||
class TextPlaceholder extends StatelessWidget {
|
||||
final double length;
|
||||
final double fontSize;
|
||||
|
||||
const TextPlaceholder({
|
||||
super.key,
|
||||
required this.length,
|
||||
required this.fontSize,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Container(
|
||||
color: Colors.white,
|
||||
width: length,
|
||||
height: fontSize,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,214 @@
|
||||
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/form_builder_fields/extended_date_range_form_field/form_builder_extended_date_range_picker.dart';
|
||||
import 'package:paperless_mobile/extensions/flutter_extensions.dart';
|
||||
import 'package:paperless_mobile/features/labels/bloc/label_cubit.dart';
|
||||
import 'package:paperless_mobile/features/labels/bloc/label_state.dart';
|
||||
import 'package:paperless_mobile/features/labels/tags/view/widgets/tags_form_field.dart';
|
||||
import 'package:paperless_mobile/features/labels/view/widgets/label_form_field.dart';
|
||||
import 'package:paperless_mobile/generated/l10n.dart';
|
||||
|
||||
import 'text_query_form_field.dart';
|
||||
|
||||
class DocumentFilterForm extends StatefulWidget {
|
||||
static const fkCorrespondent = DocumentModel.correspondentKey;
|
||||
static const fkDocumentType = DocumentModel.documentTypeKey;
|
||||
static const fkStoragePath = DocumentModel.storagePathKey;
|
||||
static const fkQuery = "query";
|
||||
static const fkCreatedAt = DocumentModel.createdKey;
|
||||
static const fkAddedAt = DocumentModel.addedKey;
|
||||
|
||||
static DocumentFilter assembleFilter(
|
||||
GlobalKey<FormBuilderState> formKey, DocumentFilter initialFilter) {
|
||||
formKey.currentState?.save();
|
||||
final v = formKey.currentState!.value;
|
||||
return DocumentFilter(
|
||||
correspondent:
|
||||
v[DocumentFilterForm.fkCorrespondent] as IdQueryParameter? ??
|
||||
DocumentFilter.initial.correspondent,
|
||||
documentType: v[DocumentFilterForm.fkDocumentType] as IdQueryParameter? ??
|
||||
DocumentFilter.initial.documentType,
|
||||
storagePath: v[DocumentFilterForm.fkStoragePath] as IdQueryParameter? ??
|
||||
DocumentFilter.initial.storagePath,
|
||||
tags:
|
||||
v[DocumentModel.tagsKey] as TagsQuery? ?? DocumentFilter.initial.tags,
|
||||
query: v[DocumentFilterForm.fkQuery] as TextQuery? ??
|
||||
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,
|
||||
);
|
||||
}
|
||||
|
||||
final Widget? header;
|
||||
final GlobalKey<FormBuilderState> formKey;
|
||||
final DocumentFilter initialFilter;
|
||||
final ScrollController? scrollController;
|
||||
final EdgeInsets padding;
|
||||
const DocumentFilterForm({
|
||||
super.key,
|
||||
this.header,
|
||||
required this.formKey,
|
||||
required this.initialFilter,
|
||||
this.scrollController,
|
||||
this.padding = const EdgeInsets.symmetric(vertical: 8, horizontal: 16),
|
||||
});
|
||||
|
||||
@override
|
||||
State<DocumentFilterForm> createState() => _DocumentFilterFormState();
|
||||
}
|
||||
|
||||
class _DocumentFilterFormState extends State<DocumentFilterForm> {
|
||||
late bool _allowOnlyExtendedQuery;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_allowOnlyExtendedQuery = widget.initialFilter.forceExtendedQuery;
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return FormBuilder(
|
||||
key: widget.formKey,
|
||||
child: CustomScrollView(
|
||||
controller: widget.scrollController,
|
||||
slivers: [
|
||||
if (widget.header != null) widget.header!,
|
||||
..._buildFormFieldList(),
|
||||
SliverToBoxAdapter(
|
||||
child: SizedBox(
|
||||
height: 32,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
List<Widget> _buildFormFieldList() {
|
||||
return [
|
||||
_buildQueryFormField(),
|
||||
Align(
|
||||
alignment: Alignment.centerLeft,
|
||||
child: Text(
|
||||
S.of(context).documentFilterAdvancedLabel,
|
||||
style: Theme.of(context).textTheme.bodySmall,
|
||||
),
|
||||
),
|
||||
FormBuilderExtendedDateRangePicker(
|
||||
name: DocumentFilterForm.fkCreatedAt,
|
||||
initialValue: widget.initialFilter.created,
|
||||
labelText: S.of(context).documentCreatedPropertyLabel,
|
||||
onChanged: (_) {
|
||||
_checkQueryConstraints();
|
||||
},
|
||||
),
|
||||
FormBuilderExtendedDateRangePicker(
|
||||
name: DocumentFilterForm.fkAddedAt,
|
||||
initialValue: widget.initialFilter.added,
|
||||
labelText: S.of(context).documentAddedPropertyLabel,
|
||||
onChanged: (_) {
|
||||
_checkQueryConstraints();
|
||||
},
|
||||
),
|
||||
_buildCorrespondentFormField(),
|
||||
_buildDocumentTypeFormField(),
|
||||
_buildStoragePathFormField(),
|
||||
_buildTagsFormField(),
|
||||
]
|
||||
.map((w) => SliverPadding(
|
||||
padding: widget.padding,
|
||||
sliver: SliverToBoxAdapter(child: w),
|
||||
))
|
||||
.toList();
|
||||
}
|
||||
|
||||
void _checkQueryConstraints() {
|
||||
final filter =
|
||||
DocumentFilterForm.assembleFilter(widget.formKey, widget.initialFilter);
|
||||
if (filter.forceExtendedQuery) {
|
||||
setState(() => _allowOnlyExtendedQuery = true);
|
||||
final queryField =
|
||||
widget.formKey.currentState?.fields[DocumentFilterForm.fkQuery];
|
||||
queryField?.didChange(
|
||||
(queryField.value as TextQuery?)
|
||||
?.copyWith(queryType: QueryType.extended),
|
||||
);
|
||||
} else {
|
||||
setState(() => _allowOnlyExtendedQuery = false);
|
||||
}
|
||||
}
|
||||
|
||||
Widget _buildDocumentTypeFormField() {
|
||||
return BlocBuilder<LabelCubit<DocumentType>, LabelState<DocumentType>>(
|
||||
builder: (context, state) {
|
||||
return LabelFormField<DocumentType>(
|
||||
formBuilderState: widget.formKey.currentState,
|
||||
name: DocumentFilterForm.fkDocumentType,
|
||||
labelOptions: state.labels,
|
||||
textFieldLabel: S.of(context).documentDocumentTypePropertyLabel,
|
||||
initialValue: widget.initialFilter.documentType,
|
||||
prefixIcon: const Icon(Icons.description_outlined),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildCorrespondentFormField() {
|
||||
return BlocBuilder<LabelCubit<Correspondent>, LabelState<Correspondent>>(
|
||||
builder: (context, state) {
|
||||
return LabelFormField<Correspondent>(
|
||||
formBuilderState: widget.formKey.currentState,
|
||||
name: DocumentFilterForm.fkCorrespondent,
|
||||
labelOptions: state.labels,
|
||||
textFieldLabel: S.of(context).documentCorrespondentPropertyLabel,
|
||||
initialValue: widget.initialFilter.correspondent,
|
||||
prefixIcon: const Icon(Icons.person_outline),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildStoragePathFormField() {
|
||||
return BlocBuilder<LabelCubit<StoragePath>, LabelState<StoragePath>>(
|
||||
builder: (context, state) {
|
||||
return LabelFormField<StoragePath>(
|
||||
formBuilderState: widget.formKey.currentState,
|
||||
name: DocumentFilterForm.fkStoragePath,
|
||||
labelOptions: state.labels,
|
||||
textFieldLabel: S.of(context).documentStoragePathPropertyLabel,
|
||||
initialValue: widget.initialFilter.storagePath,
|
||||
prefixIcon: const Icon(Icons.folder_outlined),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildQueryFormField() {
|
||||
return TextQueryFormField(
|
||||
name: DocumentFilterForm.fkQuery,
|
||||
onlyExtendedQueryAllowed: _allowOnlyExtendedQuery,
|
||||
initialValue: widget.initialFilter.query,
|
||||
);
|
||||
}
|
||||
|
||||
BlocBuilder<LabelCubit<Tag>, LabelState<Tag>> _buildTagsFormField() {
|
||||
return BlocBuilder<LabelCubit<Tag>, LabelState<Tag>>(
|
||||
builder: (context, state) {
|
||||
return TagFormField(
|
||||
name: DocumentModel.tagsKey,
|
||||
initialValue: widget.initialFilter.tags,
|
||||
allowCreation: false,
|
||||
selectableOptions: state.labels,
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -7,6 +7,7 @@ import 'package:paperless_api/paperless_api.dart';
|
||||
import 'package:paperless_mobile/core/widgets/form_builder_fields/extended_date_range_form_field/form_builder_extended_date_range_picker.dart';
|
||||
import 'package:paperless_mobile/extensions/flutter_extensions.dart';
|
||||
import 'package:paperless_mobile/features/documents/view/pages/documents_page.dart';
|
||||
import 'package:paperless_mobile/features/documents/view/widgets/search/document_filter_form.dart';
|
||||
import 'package:paperless_mobile/features/documents/view/widgets/search/text_query_form_field.dart';
|
||||
import 'package:paperless_mobile/features/labels/bloc/label_cubit.dart';
|
||||
import 'package:paperless_mobile/features/labels/bloc/label_state.dart';
|
||||
@@ -32,22 +33,14 @@ class DocumentFilterPanel extends StatefulWidget {
|
||||
}
|
||||
|
||||
class _DocumentFilterPanelState extends State<DocumentFilterPanel> {
|
||||
static const fkCorrespondent = DocumentModel.correspondentKey;
|
||||
static const fkDocumentType = DocumentModel.documentTypeKey;
|
||||
static const fkStoragePath = DocumentModel.storagePathKey;
|
||||
static const fkQuery = "query";
|
||||
static const fkCreatedAt = DocumentModel.createdKey;
|
||||
static const fkAddedAt = DocumentModel.addedKey;
|
||||
|
||||
final _formKey = GlobalKey<FormBuilderState>();
|
||||
late bool _allowOnlyExtendedQuery;
|
||||
|
||||
double _heightAnimationValue = 0;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_allowOnlyExtendedQuery = widget.initialFilter.forceExtendedQuery;
|
||||
|
||||
widget.draggableSheetController.addListener(animateTitleByDrag);
|
||||
}
|
||||
|
||||
@@ -106,100 +99,59 @@ class _DocumentFilterPanelState extends State<DocumentFilterPanel> {
|
||||
),
|
||||
),
|
||||
resizeToAvoidBottomInset: true,
|
||||
body: FormBuilder(
|
||||
key: _formKey,
|
||||
child: _buildFormList(context),
|
||||
body: DocumentFilterForm(
|
||||
formKey: _formKey,
|
||||
scrollController: widget.scrollController,
|
||||
initialFilter: widget.initialFilter,
|
||||
header: _buildPanelHeader(),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildFormList(BuildContext context) {
|
||||
return CustomScrollView(
|
||||
controller: widget.scrollController,
|
||||
slivers: [
|
||||
SliverAppBar(
|
||||
pinned: true,
|
||||
automaticallyImplyLeading: false,
|
||||
toolbarHeight: kToolbarHeight + 22,
|
||||
title: SizedBox(
|
||||
width: MediaQuery.of(context).size.width,
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.max,
|
||||
crossAxisAlignment: CrossAxisAlignment.center,
|
||||
children: [
|
||||
Opacity(
|
||||
opacity: 1 - _heightAnimationValue,
|
||||
child: Padding(
|
||||
padding: EdgeInsets.only(bottom: 11),
|
||||
child: _buildDragHandle(),
|
||||
),
|
||||
),
|
||||
Align(
|
||||
alignment: Alignment.centerLeft,
|
||||
child: Stack(
|
||||
alignment: Alignment.centerLeft,
|
||||
children: [
|
||||
Opacity(
|
||||
opacity: max(0, (_heightAnimationValue - 0.5) * 2),
|
||||
child: GestureDetector(
|
||||
onTap: () => Navigator.of(context).pop(),
|
||||
child: const Icon(Icons.expand_more_rounded),
|
||||
),
|
||||
),
|
||||
Padding(
|
||||
padding:
|
||||
EdgeInsets.only(left: _heightAnimationValue * 48),
|
||||
child: Text(S.of(context).documentFilterTitle),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
Widget _buildPanelHeader() {
|
||||
return SliverAppBar(
|
||||
pinned: true,
|
||||
automaticallyImplyLeading: false,
|
||||
toolbarHeight: kToolbarHeight + 22,
|
||||
title: SizedBox(
|
||||
width: MediaQuery.of(context).size.width,
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.max,
|
||||
crossAxisAlignment: CrossAxisAlignment.center,
|
||||
children: [
|
||||
Opacity(
|
||||
opacity: 1 - _heightAnimationValue,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.only(bottom: 11),
|
||||
child: _buildDragHandle(),
|
||||
),
|
||||
),
|
||||
),
|
||||
Align(
|
||||
alignment: Alignment.centerLeft,
|
||||
child: Stack(
|
||||
alignment: Alignment.centerLeft,
|
||||
children: [
|
||||
Opacity(
|
||||
opacity: max(0, (_heightAnimationValue - 0.5) * 2),
|
||||
child: GestureDetector(
|
||||
onTap: () => Navigator.of(context).pop(),
|
||||
child: const Icon(Icons.expand_more_rounded),
|
||||
),
|
||||
),
|
||||
Padding(
|
||||
padding: EdgeInsets.only(left: _heightAnimationValue * 48),
|
||||
child: Text(S.of(context).documentFilterTitle),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
..._buildFormFieldList(),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
List<Widget> _buildFormFieldList() {
|
||||
return [
|
||||
_buildQueryFormField().paddedSymmetrically(vertical: 8, horizontal: 16),
|
||||
Align(
|
||||
alignment: Alignment.centerLeft,
|
||||
child: Text(
|
||||
S.of(context).documentFilterAdvancedLabel,
|
||||
style: Theme.of(context).textTheme.bodySmall,
|
||||
),
|
||||
).paddedSymmetrically(vertical: 8, horizontal: 16),
|
||||
FormBuilderExtendedDateRangePicker(
|
||||
name: fkCreatedAt,
|
||||
initialValue: widget.initialFilter.created,
|
||||
labelText: S.of(context).documentCreatedPropertyLabel,
|
||||
onChanged: (_) {
|
||||
_checkQueryConstraints();
|
||||
},
|
||||
).paddedSymmetrically(vertical: 8, horizontal: 16),
|
||||
FormBuilderExtendedDateRangePicker(
|
||||
name: fkAddedAt,
|
||||
initialValue: widget.initialFilter.added,
|
||||
labelText: S.of(context).documentAddedPropertyLabel,
|
||||
onChanged: (_) {
|
||||
_checkQueryConstraints();
|
||||
},
|
||||
).paddedSymmetrically(vertical: 8, horizontal: 16),
|
||||
_buildCorrespondentFormField()
|
||||
.paddedSymmetrically(vertical: 8, horizontal: 16),
|
||||
_buildDocumentTypeFormField()
|
||||
.paddedSymmetrically(vertical: 8, horizontal: 16),
|
||||
_buildStoragePathFormField()
|
||||
.paddedSymmetrically(vertical: 8, horizontal: 16),
|
||||
_buildTagsFormField().padded(16),
|
||||
].map((w) => SliverToBoxAdapter(child: w)).toList();
|
||||
}
|
||||
|
||||
Container _buildDragHandle() {
|
||||
return Container(
|
||||
// According to m3 spec https://m3.material.io/components/bottom-sheets/specs
|
||||
@@ -212,19 +164,6 @@ class _DocumentFilterPanelState extends State<DocumentFilterPanel> {
|
||||
);
|
||||
}
|
||||
|
||||
BlocBuilder<LabelCubit<Tag>, LabelState<Tag>> _buildTagsFormField() {
|
||||
return BlocBuilder<LabelCubit<Tag>, LabelState<Tag>>(
|
||||
builder: (context, state) {
|
||||
return TagFormField(
|
||||
name: DocumentModel.tagsKey,
|
||||
initialValue: widget.initialFilter.tags,
|
||||
allowCreation: false,
|
||||
selectableOptions: state.labels,
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
void _resetFilter() async {
|
||||
FocusScope.of(context).unfocus();
|
||||
Navigator.pop(
|
||||
@@ -233,102 +172,13 @@ class _DocumentFilterPanelState extends State<DocumentFilterPanel> {
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildDocumentTypeFormField() {
|
||||
return BlocBuilder<LabelCubit<DocumentType>, LabelState<DocumentType>>(
|
||||
builder: (context, state) {
|
||||
return LabelFormField<DocumentType>(
|
||||
formBuilderState: _formKey.currentState,
|
||||
name: fkDocumentType,
|
||||
labelOptions: state.labels,
|
||||
textFieldLabel: S.of(context).documentDocumentTypePropertyLabel,
|
||||
initialValue: widget.initialFilter.documentType,
|
||||
prefixIcon: const Icon(Icons.description_outlined),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildCorrespondentFormField() {
|
||||
return BlocBuilder<LabelCubit<Correspondent>, LabelState<Correspondent>>(
|
||||
builder: (context, state) {
|
||||
return LabelFormField<Correspondent>(
|
||||
formBuilderState: _formKey.currentState,
|
||||
name: fkCorrespondent,
|
||||
labelOptions: state.labels,
|
||||
textFieldLabel: S.of(context).documentCorrespondentPropertyLabel,
|
||||
initialValue: widget.initialFilter.correspondent,
|
||||
prefixIcon: const Icon(Icons.person_outline),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildStoragePathFormField() {
|
||||
return BlocBuilder<LabelCubit<StoragePath>, LabelState<StoragePath>>(
|
||||
builder: (context, state) {
|
||||
return LabelFormField<StoragePath>(
|
||||
formBuilderState: _formKey.currentState,
|
||||
name: fkStoragePath,
|
||||
labelOptions: state.labels,
|
||||
textFieldLabel: S.of(context).documentStoragePathPropertyLabel,
|
||||
initialValue: widget.initialFilter.storagePath,
|
||||
prefixIcon: const Icon(Icons.folder_outlined),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildQueryFormField() {
|
||||
return TextQueryFormField(
|
||||
name: fkQuery,
|
||||
onlyExtendedQueryAllowed: _allowOnlyExtendedQuery,
|
||||
initialValue: widget.initialFilter.query,
|
||||
);
|
||||
}
|
||||
|
||||
void _onApplyFilter() async {
|
||||
_formKey.currentState?.save();
|
||||
if (_formKey.currentState?.validate() ?? false) {
|
||||
DocumentFilter newFilter = _assembleFilter();
|
||||
DocumentFilter newFilter =
|
||||
DocumentFilterForm.assembleFilter(_formKey, widget.initialFilter);
|
||||
FocusScope.of(context).unfocus();
|
||||
Navigator.pop(context, DocumentFilterIntent(filter: newFilter));
|
||||
}
|
||||
}
|
||||
|
||||
DocumentFilter _assembleFilter() {
|
||||
_formKey.currentState?.save();
|
||||
final v = _formKey.currentState!.value;
|
||||
return DocumentFilter(
|
||||
correspondent: v[fkCorrespondent] as IdQueryParameter? ??
|
||||
DocumentFilter.initial.correspondent,
|
||||
documentType: v[fkDocumentType] as IdQueryParameter? ??
|
||||
DocumentFilter.initial.documentType,
|
||||
storagePath: v[fkStoragePath] as IdQueryParameter? ??
|
||||
DocumentFilter.initial.storagePath,
|
||||
tags:
|
||||
v[DocumentModel.tagsKey] as TagsQuery? ?? DocumentFilter.initial.tags,
|
||||
query: v[fkQuery] as TextQuery? ?? DocumentFilter.initial.query,
|
||||
created: (v[fkCreatedAt] as DateRangeQuery),
|
||||
added: (v[fkAddedAt] as DateRangeQuery),
|
||||
asnQuery: widget.initialFilter.asnQuery,
|
||||
page: 1,
|
||||
pageSize: widget.initialFilter.pageSize,
|
||||
sortField: widget.initialFilter.sortField,
|
||||
sortOrder: widget.initialFilter.sortOrder,
|
||||
);
|
||||
}
|
||||
|
||||
void _checkQueryConstraints() {
|
||||
final filter = _assembleFilter();
|
||||
if (filter.forceExtendedQuery) {
|
||||
setState(() => _allowOnlyExtendedQuery = true);
|
||||
final queryField = _formKey.currentState?.fields[fkQuery];
|
||||
queryField?.didChange(
|
||||
(queryField.value as TextQuery?)
|
||||
?.copyWith(queryType: QueryType.extended),
|
||||
);
|
||||
} else {
|
||||
setState(() => _allowOnlyExtendedQuery = false);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
import 'package:font_awesome_flutter/font_awesome_flutter.dart';
|
||||
import 'package:paperless_api/paperless_api.dart';
|
||||
import 'package:paperless_mobile/core/translation/sort_field_localization_mapper.dart';
|
||||
import 'package:paperless_mobile/extensions/flutter_extensions.dart';
|
||||
import 'package:paperless_mobile/features/labels/bloc/label_cubit.dart';
|
||||
import 'package:paperless_mobile/features/labels/bloc/label_state.dart';
|
||||
@@ -8,9 +10,9 @@ import 'package:paperless_mobile/generated/l10n.dart';
|
||||
|
||||
class SortFieldSelectionBottomSheet extends StatefulWidget {
|
||||
final SortOrder initialSortOrder;
|
||||
final SortField initialSortField;
|
||||
final SortField? initialSortField;
|
||||
|
||||
final Future Function(SortField field, SortOrder order) onSubmit;
|
||||
final Future Function(SortField? field, SortOrder order) onSubmit;
|
||||
|
||||
const SortFieldSelectionBottomSheet({
|
||||
super.key,
|
||||
@@ -26,7 +28,7 @@ class SortFieldSelectionBottomSheet extends StatefulWidget {
|
||||
|
||||
class _SortFieldSelectionBottomSheetState
|
||||
extends State<SortFieldSelectionBottomSheet> {
|
||||
late SortField _currentSortField;
|
||||
late SortField? _currentSortField;
|
||||
late SortOrder _currentSortOrder;
|
||||
|
||||
@override
|
||||
@@ -39,61 +41,90 @@ class _SortFieldSelectionBottomSheetState
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return ClipRRect(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
Text(
|
||||
S.of(context).documentsPageOrderByLabel,
|
||||
style: Theme.of(context).textTheme.bodySmall,
|
||||
textAlign: TextAlign.start,
|
||||
),
|
||||
TextButton(
|
||||
child: Text(S.of(context).documentFilterApplyFilterLabel),
|
||||
onPressed: () {
|
||||
widget.onSubmit(
|
||||
_currentSortField,
|
||||
_currentSortOrder,
|
||||
);
|
||||
Navigator.pop(context);
|
||||
},
|
||||
),
|
||||
],
|
||||
).paddedSymmetrically(horizontal: 16, vertical: 8.0),
|
||||
Column(
|
||||
children: [
|
||||
_buildSortOption(SortField.archiveSerialNumber),
|
||||
BlocBuilder<LabelCubit<Correspondent>, LabelState<Correspondent>>(
|
||||
builder: (context, state) {
|
||||
return _buildSortOption(
|
||||
SortField.correspondentName,
|
||||
enabled: state.labels.values.fold<bool>(
|
||||
false,
|
||||
(previousValue, element) =>
|
||||
previousValue || (element.documentCount ?? 0) > 0),
|
||||
);
|
||||
},
|
||||
),
|
||||
_buildSortOption(SortField.title),
|
||||
BlocBuilder<LabelCubit<DocumentType>, LabelState<DocumentType>>(
|
||||
builder: (context, state) {
|
||||
return _buildSortOption(
|
||||
SortField.documentType,
|
||||
enabled: state.labels.values.fold<bool>(
|
||||
false,
|
||||
(previousValue, element) =>
|
||||
previousValue || (element.documentCount ?? 0) > 0),
|
||||
);
|
||||
},
|
||||
),
|
||||
_buildSortOption(SortField.created),
|
||||
_buildSortOption(SortField.added),
|
||||
_buildSortOption(SortField.modified),
|
||||
],
|
||||
),
|
||||
],
|
||||
child: SingleChildScrollView(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
Text(
|
||||
S.of(context).documentsPageOrderByLabel,
|
||||
style: Theme.of(context).textTheme.bodySmall,
|
||||
textAlign: TextAlign.start,
|
||||
),
|
||||
TextButton(
|
||||
child: Text(S.of(context).documentFilterApplyFilterLabel),
|
||||
onPressed: () {
|
||||
widget.onSubmit(
|
||||
_currentSortField,
|
||||
_currentSortOrder,
|
||||
);
|
||||
Navigator.pop(context);
|
||||
},
|
||||
),
|
||||
],
|
||||
).paddedOnly(left: 16, right: 16, top: 8),
|
||||
Column(
|
||||
children: [
|
||||
_buildSortOption(SortField.archiveSerialNumber),
|
||||
BlocBuilder<LabelCubit<Correspondent>,
|
||||
LabelState<Correspondent>>(
|
||||
builder: (context, state) {
|
||||
return _buildSortOption(
|
||||
SortField.correspondentName,
|
||||
enabled: state.labels.values.fold<bool>(
|
||||
false,
|
||||
(previousValue, element) =>
|
||||
previousValue ||
|
||||
(element.documentCount ?? 0) > 0),
|
||||
);
|
||||
},
|
||||
),
|
||||
_buildSortOption(SortField.title),
|
||||
BlocBuilder<LabelCubit<DocumentType>, LabelState<DocumentType>>(
|
||||
builder: (context, state) {
|
||||
return _buildSortOption(
|
||||
SortField.documentType,
|
||||
enabled: state.labels.values.fold<bool>(
|
||||
false,
|
||||
(previousValue, element) =>
|
||||
previousValue ||
|
||||
(element.documentCount ?? 0) > 0),
|
||||
);
|
||||
},
|
||||
),
|
||||
_buildSortOption(SortField.created),
|
||||
_buildSortOption(SortField.added),
|
||||
_buildSortOption(SortField.modified),
|
||||
const SizedBox(height: 16),
|
||||
Center(
|
||||
child: SegmentedButton(
|
||||
multiSelectionEnabled: false,
|
||||
showSelectedIcon: false,
|
||||
segments: [
|
||||
ButtonSegment(
|
||||
icon: const FaIcon(FontAwesomeIcons.arrowDownAZ),
|
||||
value: SortOrder.descending,
|
||||
label: Text(S.of(context).sortDocumentDescending),
|
||||
),
|
||||
ButtonSegment(
|
||||
icon: const FaIcon(FontAwesomeIcons.arrowUpZA),
|
||||
value: SortOrder.ascending,
|
||||
label: Text(S.of(context).sortDocumentAscending),
|
||||
),
|
||||
],
|
||||
emptySelectionAllowed: false,
|
||||
selected: {_currentSortOrder},
|
||||
onSelectionChanged: (selection) {
|
||||
setState(() => _currentSortOrder = selection.first);
|
||||
},
|
||||
),
|
||||
).paddedOnly(bottom: 16),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
@@ -101,47 +132,10 @@ class _SortFieldSelectionBottomSheetState
|
||||
Widget _buildSortOption(SortField field, {bool enabled = true}) {
|
||||
return ListTile(
|
||||
enabled: enabled,
|
||||
contentPadding: const EdgeInsets.symmetric(horizontal: 32),
|
||||
title: Text(
|
||||
_localizedSortField(field),
|
||||
),
|
||||
trailing: _currentSortField == field
|
||||
? _buildOrderIcon(_currentSortOrder)
|
||||
: null,
|
||||
onTap: () {
|
||||
setState(() {
|
||||
_currentSortOrder = (_currentSortField == field
|
||||
? _currentSortOrder.toggle()
|
||||
: SortOrder.descending);
|
||||
_currentSortField = field;
|
||||
});
|
||||
},
|
||||
contentPadding: const EdgeInsets.only(left: 32, right: 16),
|
||||
title: Text(translateSortField(context, field)),
|
||||
trailing: _currentSortField == field ? const Icon(Icons.done) : null,
|
||||
onTap: () => setState(() => _currentSortField = field),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildOrderIcon(SortOrder order) {
|
||||
if (order == SortOrder.ascending) {
|
||||
return const Icon(Icons.arrow_upward);
|
||||
}
|
||||
return const Icon(Icons.arrow_downward);
|
||||
}
|
||||
|
||||
String _localizedSortField(SortField sortField) {
|
||||
switch (sortField) {
|
||||
case SortField.archiveSerialNumber:
|
||||
return S.of(context).documentArchiveSerialNumberPropertyShortLabel;
|
||||
case SortField.correspondentName:
|
||||
return S.of(context).documentCorrespondentPropertyLabel;
|
||||
case SortField.title:
|
||||
return S.of(context).documentTitlePropertyLabel;
|
||||
case SortField.documentType:
|
||||
return S.of(context).documentDocumentTypePropertyLabel;
|
||||
case SortField.created:
|
||||
return S.of(context).documentCreatedPropertyLabel;
|
||||
case SortField.added:
|
||||
return S.of(context).documentAddedPropertyLabel;
|
||||
case SortField.modified:
|
||||
return S.of(context).documentModifiedPropertyLabel;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,156 +0,0 @@
|
||||
import 'dart:developer';
|
||||
|
||||
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/offline_banner.dart';
|
||||
import 'package:paperless_mobile/features/documents/bloc/documents_cubit.dart';
|
||||
import 'package:paperless_mobile/features/documents/bloc/documents_state.dart';
|
||||
import 'package:paperless_mobile/features/documents/view/widgets/selection/bulk_delete_confirmation_dialog.dart';
|
||||
import 'package:paperless_mobile/features/saved_view/view/saved_view_selection_widget.dart';
|
||||
import 'package:paperless_mobile/generated/l10n.dart';
|
||||
import 'package:paperless_mobile/util.dart';
|
||||
import 'package:paperless_mobile/extensions/flutter_extensions.dart';
|
||||
|
||||
class DocumentsPageAppBar extends StatefulWidget with PreferredSizeWidget {
|
||||
final List<Widget> actions;
|
||||
final bool isOffline;
|
||||
|
||||
const DocumentsPageAppBar({
|
||||
super.key,
|
||||
required this.isOffline,
|
||||
this.actions = const [],
|
||||
});
|
||||
@override
|
||||
Size get preferredSize => const Size.fromHeight(kToolbarHeight);
|
||||
@override
|
||||
State<DocumentsPageAppBar> createState() => _DocumentsPageAppBarState();
|
||||
}
|
||||
|
||||
class _DocumentsPageAppBarState extends State<DocumentsPageAppBar> {
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
const savedViewWidgetHeight = 48.0;
|
||||
final flexibleAreaHeight = kToolbarHeight -
|
||||
16 +
|
||||
savedViewWidgetHeight +
|
||||
(widget.isOffline ? 24 : 0);
|
||||
return BlocBuilder<DocumentsCubit, DocumentsState>(
|
||||
builder: (context, documentsState) {
|
||||
final hasSelection = documentsState.selection.isNotEmpty;
|
||||
// final PreferredSize? loadingWidget = documentsState.isLoading
|
||||
// ? const PreferredSize(
|
||||
// child: LinearProgressIndicator(),
|
||||
// preferredSize: Size.fromHeight(4.0),
|
||||
// )
|
||||
// : null;
|
||||
if (hasSelection) {
|
||||
return SliverAppBar(
|
||||
// bottom: loadingWidget,
|
||||
expandedHeight: kToolbarHeight + flexibleAreaHeight,
|
||||
snap: true,
|
||||
floating: true,
|
||||
pinned: true,
|
||||
flexibleSpace: _buildFlexibleArea(
|
||||
false,
|
||||
documentsState.filter,
|
||||
savedViewWidgetHeight,
|
||||
),
|
||||
leading: IconButton(
|
||||
icon: const Icon(Icons.close),
|
||||
onPressed: () => context.read<DocumentsCubit>().resetSelection(),
|
||||
),
|
||||
title: Text(
|
||||
'${documentsState.selection.length} ${S.of(context).documentsSelectedText}'),
|
||||
actions: [
|
||||
IconButton(
|
||||
icon: const Icon(Icons.delete),
|
||||
onPressed: () => _onDelete(context, documentsState),
|
||||
),
|
||||
],
|
||||
);
|
||||
} else {
|
||||
return SliverAppBar(
|
||||
// bottom: loadingWidget,
|
||||
expandedHeight: kToolbarHeight + flexibleAreaHeight,
|
||||
snap: true,
|
||||
floating: true,
|
||||
pinned: true,
|
||||
flexibleSpace: _buildFlexibleArea(
|
||||
true,
|
||||
documentsState.filter,
|
||||
savedViewWidgetHeight,
|
||||
),
|
||||
title: Text(
|
||||
'${S.of(context).documentsPageTitle} (${_formatDocumentCount(documentsState.count)})',
|
||||
),
|
||||
actions: [
|
||||
...widget.actions,
|
||||
],
|
||||
);
|
||||
}
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildFlexibleArea(
|
||||
bool enabled,
|
||||
DocumentFilter filter,
|
||||
double savedViewHeight,
|
||||
) {
|
||||
return FlexibleSpaceBar(
|
||||
background: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.end,
|
||||
children: [
|
||||
if (widget.isOffline) const OfflineBanner(),
|
||||
SavedViewSelectionWidget(
|
||||
height: savedViewHeight,
|
||||
enabled: enabled,
|
||||
currentFilter: filter,
|
||||
).paddedSymmetrically(horizontal: 8.0),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
void _onDelete(BuildContext context, DocumentsState documentsState) async {
|
||||
final shouldDelete = await showDialog<bool>(
|
||||
context: context,
|
||||
builder: (context) =>
|
||||
BulkDeleteConfirmationDialog(state: documentsState)) ??
|
||||
false;
|
||||
if (shouldDelete) {
|
||||
try {
|
||||
await context
|
||||
.read<DocumentsCubit>()
|
||||
.bulkRemove(documentsState.selection);
|
||||
showSnackBar(
|
||||
context,
|
||||
S.of(context).documentsPageBulkDeleteSuccessfulText,
|
||||
);
|
||||
} on PaperlessServerException catch (error, stackTrace) {
|
||||
showErrorMessage(context, error, stackTrace);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
String _formatDocumentCount(int count) {
|
||||
return count > 99 ? "99+" : count.toString();
|
||||
}
|
||||
}
|
||||
|
||||
class ScrollListener extends ChangeNotifier {
|
||||
double top = 0;
|
||||
double _last = 0;
|
||||
|
||||
ScrollListener.initialise(ScrollController controller, [double height = 56]) {
|
||||
controller.addListener(() {
|
||||
final current = controller.offset;
|
||||
top += _last - current;
|
||||
if (top <= -height) top = -height;
|
||||
if (top >= 0) top = 0;
|
||||
_last = current;
|
||||
if (top <= 0 && top >= -height) notifyListeners();
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -4,72 +4,72 @@ import 'package:paperless_api/paperless_api.dart';
|
||||
import 'package:paperless_mobile/core/repository/label_repository.dart';
|
||||
import 'package:paperless_mobile/core/repository/state/impl/correspondent_repository_state.dart';
|
||||
import 'package:paperless_mobile/core/repository/state/impl/document_type_repository_state.dart';
|
||||
import 'package:paperless_mobile/core/translation/sort_field_localization_mapper.dart';
|
||||
import 'package:paperless_mobile/features/documents/bloc/documents_cubit.dart';
|
||||
import 'package:paperless_mobile/features/documents/bloc/documents_state.dart';
|
||||
import 'package:paperless_mobile/features/documents/view/widgets/search/sort_field_selection_bottom_sheet.dart';
|
||||
import 'package:paperless_mobile/features/labels/bloc/label_cubit.dart';
|
||||
|
||||
class SortDocumentsButton extends StatelessWidget {
|
||||
const SortDocumentsButton({super.key});
|
||||
const SortDocumentsButton({
|
||||
super.key,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return IconButton(
|
||||
icon: const Icon(Icons.sort),
|
||||
onPressed: () => _onOpenSortBottomSheet(context),
|
||||
);
|
||||
}
|
||||
|
||||
void _onOpenSortBottomSheet(BuildContext context) {
|
||||
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: FractionallySizedBox(
|
||||
heightFactor: .6,
|
||||
child: MultiBlocProvider(
|
||||
providers: [
|
||||
BlocProvider(
|
||||
create: (context) => LabelCubit<DocumentType>(
|
||||
context.read<
|
||||
LabelRepository<DocumentType,
|
||||
DocumentTypeRepositoryState>>(),
|
||||
return BlocBuilder<DocumentsCubit, DocumentsState>(
|
||||
builder: (context, state) {
|
||||
if (state.filter.sortField == null) {
|
||||
return const SizedBox.shrink();
|
||||
}
|
||||
return TextButton.icon(
|
||||
icon: Icon(state.filter.sortOrder == SortOrder.ascending
|
||||
? Icons.arrow_upward
|
||||
: Icons.arrow_downward),
|
||||
label: Text(translateSortField(context, state.filter.sortField)),
|
||||
onPressed: () {
|
||||
showModalBottomSheet(
|
||||
elevation: 2,
|
||||
context: context,
|
||||
isScrollControlled: true,
|
||||
shape: const RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.only(
|
||||
topLeft: Radius.circular(16),
|
||||
topRight: Radius.circular(16),
|
||||
),
|
||||
),
|
||||
BlocProvider(
|
||||
create: (context) => LabelCubit<Correspondent>(
|
||||
context.read<
|
||||
LabelRepository<Correspondent,
|
||||
CorrespondentRepositoryState>>(),
|
||||
),
|
||||
),
|
||||
],
|
||||
child: BlocBuilder<DocumentsCubit, DocumentsState>(
|
||||
builder: (context, state) {
|
||||
return SortFieldSelectionBottomSheet(
|
||||
initialSortField: state.filter.sortField,
|
||||
initialSortOrder: state.filter.sortOrder,
|
||||
onSubmit: (field, order) =>
|
||||
context.read<DocumentsCubit>().updateCurrentFilter(
|
||||
(filter) => filter.copyWith(
|
||||
sortField: field,
|
||||
sortOrder: order,
|
||||
builder: (_) => BlocProvider<DocumentsCubit>.value(
|
||||
value: context.read<DocumentsCubit>(),
|
||||
child: MultiBlocProvider(
|
||||
providers: [
|
||||
BlocProvider(
|
||||
create: (context) => LabelCubit<DocumentType>(
|
||||
context.read<LabelRepository<DocumentType>>(),
|
||||
),
|
||||
),
|
||||
BlocProvider(
|
||||
create: (context) => LabelCubit<Correspondent>(
|
||||
context.read<LabelRepository<Correspondent>>(),
|
||||
),
|
||||
),
|
||||
],
|
||||
child: SortFieldSelectionBottomSheet(
|
||||
initialSortField: state.filter.sortField,
|
||||
initialSortOrder: state.filter.sortOrder,
|
||||
onSubmit: (field, order) =>
|
||||
context.read<DocumentsCubit>().updateCurrentFilter(
|
||||
(filter) => filter.copyWith(
|
||||
sortField: field,
|
||||
sortOrder: order,
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
39
lib/features/documents/view/widgets/view_actions.dart
Normal file
39
lib/features/documents/view/widgets/view_actions.dart
Normal file
@@ -0,0 +1,39 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
import 'package:paperless_mobile/features/documents/view/widgets/sort_documents_button.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';
|
||||
|
||||
class ViewActions extends StatelessWidget {
|
||||
const ViewActions({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
const SortDocumentsButton(),
|
||||
BlocBuilder<ApplicationSettingsCubit, ApplicationSettingsState>(
|
||||
builder: (context, settings) {
|
||||
final cubit = context.read<ApplicationSettingsCubit>();
|
||||
switch (settings.preferredViewType) {
|
||||
case ViewType.grid:
|
||||
return IconButton(
|
||||
icon: const Icon(Icons.list),
|
||||
onPressed: () =>
|
||||
cubit.setViewType(settings.preferredViewType.toggle()),
|
||||
);
|
||||
case ViewType.list:
|
||||
return IconButton(
|
||||
icon: const Icon(Icons.grid_view_rounded),
|
||||
onPressed: () =>
|
||||
cubit.setViewType(settings.preferredViewType.toggle()),
|
||||
);
|
||||
}
|
||||
},
|
||||
)
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -3,6 +3,7 @@ import 'dart:async';
|
||||
import 'package:bloc/bloc.dart';
|
||||
import 'package:equatable/equatable.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:collection/collection.dart';
|
||||
import 'package:paperless_mobile/core/repository/state/impl/correspondent_repository_state.dart';
|
||||
@@ -16,31 +17,28 @@ class EditDocumentCubit extends Cubit<EditDocumentState> {
|
||||
final DocumentModel _initialDocument;
|
||||
final PaperlessDocumentsApi _docsApi;
|
||||
|
||||
final LabelRepository<Correspondent, CorrespondentRepositoryState>
|
||||
_correspondentRepository;
|
||||
final LabelRepository<DocumentType, DocumentTypeRepositoryState>
|
||||
_documentTypeRepository;
|
||||
final LabelRepository<StoragePath, StoragePathRepositoryState>
|
||||
_storagePathRepository;
|
||||
final LabelRepository<Tag, TagRepositoryState> _tagRepository;
|
||||
|
||||
final DocumentChangedNotifier _notifier;
|
||||
final LabelRepository<Correspondent> _correspondentRepository;
|
||||
final LabelRepository<DocumentType> _documentTypeRepository;
|
||||
final LabelRepository<StoragePath> _storagePathRepository;
|
||||
final LabelRepository<Tag> _tagRepository;
|
||||
final List<StreamSubscription> _subscriptions = [];
|
||||
|
||||
EditDocumentCubit(
|
||||
DocumentModel document, {
|
||||
required PaperlessDocumentsApi documentsApi,
|
||||
required LabelRepository<Correspondent, CorrespondentRepositoryState>
|
||||
correspondentRepository,
|
||||
required LabelRepository<DocumentType, DocumentTypeRepositoryState>
|
||||
documentTypeRepository,
|
||||
required LabelRepository<StoragePath, StoragePathRepositoryState>
|
||||
storagePathRepository,
|
||||
required LabelRepository<Tag, TagRepositoryState> tagRepository,
|
||||
required LabelRepository<Correspondent> correspondentRepository,
|
||||
required LabelRepository<DocumentType> documentTypeRepository,
|
||||
required LabelRepository<StoragePath> storagePathRepository,
|
||||
required LabelRepository<Tag> tagRepository,
|
||||
required DocumentChangedNotifier notifier,
|
||||
}) : _initialDocument = document,
|
||||
_docsApi = documentsApi,
|
||||
_correspondentRepository = correspondentRepository,
|
||||
_documentTypeRepository = documentTypeRepository,
|
||||
_storagePathRepository = storagePathRepository,
|
||||
_tagRepository = tagRepository,
|
||||
_notifier = notifier,
|
||||
super(
|
||||
EditDocumentState(
|
||||
document: document,
|
||||
@@ -50,6 +48,7 @@ class EditDocumentCubit extends Cubit<EditDocumentState> {
|
||||
tags: tagRepository.current?.values ?? {},
|
||||
),
|
||||
) {
|
||||
_notifier.subscribe(this, onUpdated: replace);
|
||||
_subscriptions.add(
|
||||
_correspondentRepository.values
|
||||
.listen((v) => emit(state.copyWith(correspondents: v?.values))),
|
||||
@@ -71,6 +70,8 @@ class EditDocumentCubit extends Cubit<EditDocumentState> {
|
||||
|
||||
Future<void> updateDocument(DocumentModel document) async {
|
||||
final updated = await _docsApi.update(document);
|
||||
_notifier.notifyUpdated(updated);
|
||||
|
||||
// Reload changed labels (documentCount property changes with removal/add)
|
||||
if (document.documentType != _initialDocument.documentType) {
|
||||
_documentTypeRepository
|
||||
@@ -88,7 +89,10 @@ class EditDocumentCubit extends Cubit<EditDocumentState> {
|
||||
.equals(document.tags, _initialDocument.tags)) {
|
||||
_tagRepository.findAll(document.tags);
|
||||
}
|
||||
emit(state.copyWith(document: updated));
|
||||
}
|
||||
|
||||
void replace(DocumentModel document) {
|
||||
emit(state.copyWith(document: document));
|
||||
}
|
||||
|
||||
@override
|
||||
@@ -96,6 +100,7 @@ class EditDocumentCubit extends Cubit<EditDocumentState> {
|
||||
for (final sub in _subscriptions) {
|
||||
sub.cancel();
|
||||
}
|
||||
_notifier.unsubscribe(this);
|
||||
return super.close();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,15 +3,15 @@ import 'dart:async';
|
||||
import 'package:bloc/bloc.dart';
|
||||
import 'package:paperless_api/paperless_api.dart';
|
||||
import 'package:paperless_mobile/core/repository/label_repository.dart';
|
||||
import 'package:paperless_mobile/core/repository/state/repository_state.dart';
|
||||
import 'package:paperless_mobile/core/repository/state/indexed_repository_state.dart';
|
||||
import 'package:paperless_mobile/features/edit_label/cubit/edit_label_state.dart';
|
||||
|
||||
class EditLabelCubit<T extends Label> extends Cubit<EditLabelState<T>> {
|
||||
final LabelRepository<T, RepositoryState<Map<int, T>>> _repository;
|
||||
final LabelRepository<T> _repository;
|
||||
|
||||
StreamSubscription? _subscription;
|
||||
|
||||
EditLabelCubit(LabelRepository<T, RepositoryState<Map<int, T>>> repository)
|
||||
EditLabelCubit(LabelRepository<T> repository)
|
||||
: _repository = repository,
|
||||
super(const EditLabelInitial()) {
|
||||
_subscription = repository.values.listen(
|
||||
|
||||
@@ -2,7 +2,7 @@ import 'package:flutter/material.dart';
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
import 'package:paperless_api/paperless_api.dart';
|
||||
import 'package:paperless_mobile/core/repository/label_repository.dart';
|
||||
import 'package:paperless_mobile/core/repository/state/repository_state.dart';
|
||||
import 'package:paperless_mobile/core/repository/state/indexed_repository_state.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.dart';
|
||||
@@ -25,8 +25,7 @@ class AddLabelPage<T extends Label> extends StatelessWidget {
|
||||
Widget build(BuildContext context) {
|
||||
return BlocProvider(
|
||||
create: (context) => EditLabelCubit(
|
||||
context
|
||||
.read<LabelRepository<Label, RepositoryState<Map<int, Label>>>>(),
|
||||
context.read<LabelRepository<T>>(),
|
||||
),
|
||||
child: AddLabelFormWidget(
|
||||
pageTitle: pageTitle,
|
||||
|
||||
@@ -5,11 +5,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/repository/label_repository.dart';
|
||||
import 'package:paperless_mobile/core/repository/state/repository_state.dart';
|
||||
import 'package:paperless_mobile/core/repository/state/indexed_repository_state.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.dart';
|
||||
import 'package:paperless_mobile/util.dart';
|
||||
import 'package:paperless_mobile/helpers/message_helpers.dart';
|
||||
import 'package:paperless_mobile/constants.dart';
|
||||
|
||||
class EditLabelPage<T extends Label> extends StatelessWidget {
|
||||
final T label;
|
||||
@@ -27,8 +28,7 @@ class EditLabelPage<T extends Label> extends StatelessWidget {
|
||||
Widget build(BuildContext context) {
|
||||
return BlocProvider(
|
||||
create: (context) => EditLabelCubit(
|
||||
context
|
||||
.read<LabelRepository<Label, RepositoryState<Map<int, Label>>>>(),
|
||||
context.read<LabelRepository<T>>(),
|
||||
),
|
||||
child: EditLabelForm(
|
||||
label: label,
|
||||
|
||||
@@ -15,8 +15,7 @@ class AddCorrespondentPage extends StatelessWidget {
|
||||
Widget build(BuildContext context) {
|
||||
return BlocProvider(
|
||||
create: (context) => EditLabelCubit<Correspondent>(
|
||||
context.read<
|
||||
LabelRepository<Correspondent, CorrespondentRepositoryState>>(),
|
||||
context.read<LabelRepository<Correspondent>>(),
|
||||
),
|
||||
child: AddLabelPage<Correspondent>(
|
||||
pageTitle: Text(S.of(context).addCorrespondentPageTitle),
|
||||
|
||||
@@ -18,8 +18,7 @@ class AddDocumentTypePage extends StatelessWidget {
|
||||
Widget build(BuildContext context) {
|
||||
return BlocProvider(
|
||||
create: (context) => EditLabelCubit<DocumentType>(
|
||||
context
|
||||
.read<LabelRepository<DocumentType, DocumentTypeRepositoryState>>(),
|
||||
context.read<LabelRepository<DocumentType>>(),
|
||||
),
|
||||
child: AddLabelPage<DocumentType>(
|
||||
pageTitle: Text(S.of(context).addDocumentTypePageTitle),
|
||||
|
||||
@@ -16,8 +16,7 @@ class AddStoragePathPage extends StatelessWidget {
|
||||
Widget build(BuildContext context) {
|
||||
return BlocProvider(
|
||||
create: (context) => EditLabelCubit<StoragePath>(
|
||||
context
|
||||
.read<LabelRepository<StoragePath, StoragePathRepositoryState>>(),
|
||||
context.read<LabelRepository<StoragePath>>(),
|
||||
),
|
||||
child: AddLabelPage<StoragePath>(
|
||||
pageTitle: Text(S.of(context).addStoragePathPageTitle),
|
||||
|
||||
@@ -19,7 +19,7 @@ class AddTagPage extends StatelessWidget {
|
||||
Widget build(BuildContext context) {
|
||||
return BlocProvider(
|
||||
create: (context) => EditLabelCubit<Tag>(
|
||||
context.read<LabelRepository<Tag, TagRepositoryState>>(),
|
||||
context.read<LabelRepository<Tag>>(),
|
||||
),
|
||||
child: AddLabelPage<Tag>(
|
||||
pageTitle: Text(S.of(context).addTagPageTitle),
|
||||
|
||||
@@ -14,8 +14,7 @@ class EditCorrespondentPage extends StatelessWidget {
|
||||
Widget build(BuildContext context) {
|
||||
return BlocProvider(
|
||||
create: (context) => EditLabelCubit<Correspondent>(
|
||||
context.read<
|
||||
LabelRepository<Correspondent, CorrespondentRepositoryState>>(),
|
||||
context.read<LabelRepository<Correspondent>>(),
|
||||
),
|
||||
child: EditLabelPage<Correspondent>(
|
||||
label: correspondent,
|
||||
|
||||
@@ -14,8 +14,7 @@ class EditDocumentTypePage extends StatelessWidget {
|
||||
Widget build(BuildContext context) {
|
||||
return BlocProvider(
|
||||
create: (context) => EditLabelCubit<DocumentType>(
|
||||
context
|
||||
.read<LabelRepository<DocumentType, DocumentTypeRepositoryState>>(),
|
||||
context.read<LabelRepository<DocumentType>>(),
|
||||
),
|
||||
child: EditLabelPage<DocumentType>(
|
||||
label: documentType,
|
||||
|
||||
@@ -15,8 +15,7 @@ class EditStoragePathPage extends StatelessWidget {
|
||||
Widget build(BuildContext context) {
|
||||
return BlocProvider(
|
||||
create: (context) => EditLabelCubit<StoragePath>(
|
||||
context
|
||||
.read<LabelRepository<StoragePath, StoragePathRepositoryState>>(),
|
||||
context.read<LabelRepository<StoragePath>>(),
|
||||
),
|
||||
child: EditLabelPage<StoragePath>(
|
||||
label: storagePath,
|
||||
|
||||
@@ -18,7 +18,7 @@ class EditTagPage extends StatelessWidget {
|
||||
Widget build(BuildContext context) {
|
||||
return BlocProvider(
|
||||
create: (context) => EditLabelCubit<Tag>(
|
||||
context.read<LabelRepository<Tag, TagRepositoryState>>(),
|
||||
context.read<LabelRepository<Tag>>(),
|
||||
),
|
||||
child: EditLabelPage<Tag>(
|
||||
label: tag,
|
||||
|
||||
@@ -6,7 +6,8 @@ import 'package:paperless_mobile/core/translation/matching_algorithm_localizatio
|
||||
import 'package:paperless_mobile/core/type/types.dart';
|
||||
import 'package:paperless_mobile/extensions/flutter_extensions.dart';
|
||||
import 'package:paperless_mobile/generated/l10n.dart';
|
||||
import 'package:paperless_mobile/util.dart';
|
||||
import 'package:paperless_mobile/helpers/message_helpers.dart';
|
||||
import 'package:paperless_mobile/constants.dart';
|
||||
|
||||
class SubmitButtonConfig<T extends Label> {
|
||||
final Widget icon;
|
||||
@@ -53,8 +54,9 @@ class _LabelFormState<T extends Label> extends State<LabelForm<T>> {
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_enableMatchFormField =
|
||||
widget.initialValue?.matchingAlgorithm != MatchingAlgorithm.auto;
|
||||
_enableMatchFormField = (widget.initialValue?.matchingAlgorithm ??
|
||||
MatchingAlgorithm.defaultValue) !=
|
||||
MatchingAlgorithm.auto;
|
||||
}
|
||||
|
||||
@override
|
||||
@@ -82,8 +84,9 @@ class _LabelFormState<T extends Label> extends State<LabelForm<T>> {
|
||||
),
|
||||
FormBuilderDropdown<int?>(
|
||||
name: Label.matchingAlgorithmKey,
|
||||
initialValue: widget.initialValue?.matchingAlgorithm.value ??
|
||||
MatchingAlgorithm.auto.value,
|
||||
initialValue: (widget.initialValue?.matchingAlgorithm ??
|
||||
MatchingAlgorithm.defaultValue)
|
||||
.value,
|
||||
decoration: InputDecoration(
|
||||
labelText: S.of(context).labelMatchingAlgorithmPropertyLabel,
|
||||
errorText: _errors[Label.matchingAlgorithmKey],
|
||||
@@ -98,7 +101,8 @@ class _LabelFormState<T extends Label> extends State<LabelForm<T>> {
|
||||
.map(
|
||||
(algo) => DropdownMenuItem<int?>(
|
||||
child: Text(
|
||||
translateMatchingAlgorithmDescription(context, algo)),
|
||||
translateMatchingAlgorithmDescription(context, algo),
|
||||
),
|
||||
value: algo.value,
|
||||
),
|
||||
)
|
||||
@@ -138,8 +142,8 @@ class _LabelFormState<T extends Label> extends State<LabelForm<T>> {
|
||||
// If auto is selected, the match will be removed.
|
||||
mergedJson[Label.matchKey] = '';
|
||||
}
|
||||
final createdLabel = await widget.submitButtonConfig
|
||||
.onSubmit(widget.fromJsonT(mergedJson));
|
||||
final parsed = widget.fromJsonT(mergedJson);
|
||||
final createdLabel = await widget.submitButtonConfig.onSubmit(parsed);
|
||||
Navigator.pop(context, createdLabel);
|
||||
} on PaperlessServerException catch (error, stackTrace) {
|
||||
showErrorMessage(context, error, stackTrace);
|
||||
|
||||
@@ -8,20 +8,22 @@ import 'package:paperless_api/paperless_api.dart';
|
||||
import 'package:paperless_mobile/core/bloc/connectivity_cubit.dart';
|
||||
import 'package:paperless_mobile/core/bloc/paperless_server_information_cubit.dart';
|
||||
import 'package:paperless_mobile/core/global/constants.dart';
|
||||
import 'package:paperless_mobile/core/translation/error_code_localization_mapper.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/state/impl/correspondent_repository_state.dart';
|
||||
import 'package:paperless_mobile/core/repository/state/impl/document_type_repository_state.dart';
|
||||
import 'package:paperless_mobile/core/repository/state/impl/storage_path_repository_state.dart';
|
||||
import 'package:paperless_mobile/core/repository/state/impl/tag_repository_state.dart';
|
||||
import 'package:paperless_mobile/core/translation/error_code_localization_mapper.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/documents/bloc/documents_cubit.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/home/view/widget/bottom_navigation_bar.dart';
|
||||
import 'package:paperless_mobile/features/home/view/widget/app_drawer.dart';
|
||||
import 'package:paperless_mobile/features/inbox/bloc/inbox_cubit.dart';
|
||||
import 'package:paperless_mobile/features/inbox/bloc/state/inbox_state.dart';
|
||||
import 'package:paperless_mobile/features/inbox/view/pages/inbox_page.dart';
|
||||
import 'package:paperless_mobile/features/labels/bloc/label_cubit.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/saved_view/cubit/saved_view_cubit.dart';
|
||||
@@ -30,9 +32,10 @@ import 'package:paperless_mobile/features/scan/view/scanner_page.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.dart';
|
||||
import 'package:paperless_mobile/util.dart';
|
||||
import 'package:receive_sharing_intent/receive_sharing_intent.dart';
|
||||
import 'package:paperless_mobile/helpers/file_helpers.dart';
|
||||
import 'package:paperless_mobile/helpers/message_helpers.dart';
|
||||
import 'package:path/path.dart' as p;
|
||||
import 'package:receive_sharing_intent/receive_sharing_intent.dart';
|
||||
import 'package:responsive_builder/responsive_builder.dart';
|
||||
|
||||
class HomePage extends StatefulWidget {
|
||||
@@ -45,11 +48,20 @@ class HomePage extends StatefulWidget {
|
||||
class _HomePageState extends State<HomePage> {
|
||||
int _currentIndex = 0;
|
||||
final DocumentScannerCubit _scannerCubit = DocumentScannerCubit();
|
||||
late final InboxCubit _inboxCubit;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_initializeData(context);
|
||||
_inboxCubit = InboxCubit(
|
||||
context.read(),
|
||||
context.read(),
|
||||
context.read(),
|
||||
context.read(),
|
||||
context.read(),
|
||||
context.read(),
|
||||
);
|
||||
context.read<ConnectivityCubit>().reload();
|
||||
WidgetsBinding.instance.addPostFrameCallback((timeStamp) {
|
||||
_listenForReceivedFiles();
|
||||
@@ -109,7 +121,6 @@ class _HomePageState extends State<HomePage> {
|
||||
MaterialPageRoute(
|
||||
builder: (context) => BlocProvider.value(
|
||||
value: DocumentUploadCubit(
|
||||
localVault: context.read(),
|
||||
documentApi: context.read(),
|
||||
tagRepository: context.read(),
|
||||
correspondentRepository: context.read(),
|
||||
@@ -137,7 +148,7 @@ class _HomePageState extends State<HomePage> {
|
||||
toastLength: Toast.LENGTH_LONG,
|
||||
);
|
||||
}
|
||||
} catch (e, stackTrace) {
|
||||
} catch (e) {
|
||||
Fluttertoast.showToast(
|
||||
msg: S.of(context).receiveSharedFilePermissionDeniedMessage,
|
||||
toastLength: Toast.LENGTH_LONG,
|
||||
@@ -145,6 +156,12 @@ class _HomePageState extends State<HomePage> {
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_inboxCubit.close();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final destinations = [
|
||||
@@ -172,35 +189,38 @@ class _HomePageState extends State<HomePage> {
|
||||
),
|
||||
label: S.of(context).bottomNavLabelsPageLabel,
|
||||
),
|
||||
// RouteDescription(
|
||||
// icon: const Icon(Icons.inbox_outlined),
|
||||
// selectedIcon: Icon(
|
||||
// Icons.inbox,
|
||||
// color: Theme.of(context).colorScheme.primary,
|
||||
// ),
|
||||
// label: S.of(context).bottomNavInboxPageLabel,
|
||||
// ),
|
||||
// RouteDescription(
|
||||
// icon: const Icon(Icons.settings_outlined),
|
||||
// selectedIcon: Icon(
|
||||
// Icons.settings,
|
||||
// color: Theme.of(context).colorScheme.primary,
|
||||
// ),
|
||||
// label: S.of(context).appDrawerSettingsLabel,
|
||||
// ),
|
||||
RouteDescription(
|
||||
icon: const Icon(Icons.inbox_outlined),
|
||||
selectedIcon: Icon(
|
||||
Icons.inbox,
|
||||
color: Theme.of(context).colorScheme.primary,
|
||||
),
|
||||
label: S.of(context).bottomNavInboxPageLabel,
|
||||
badgeBuilder: (icon) => BlocBuilder<InboxCubit, InboxState>(
|
||||
bloc: _inboxCubit,
|
||||
builder: (context, state) {
|
||||
if (state.itemsInInboxCount > 0) {
|
||||
return Badge.count(
|
||||
count: state.itemsInInboxCount,
|
||||
child: icon,
|
||||
);
|
||||
}
|
||||
return icon;
|
||||
},
|
||||
)),
|
||||
];
|
||||
final routes = <Widget>[
|
||||
MultiBlocProvider(
|
||||
providers: [
|
||||
BlocProvider(
|
||||
create: (context) => DocumentsCubit(
|
||||
context.read<PaperlessDocumentsApi>(),
|
||||
context.read<SavedViewRepository>(),
|
||||
context.read(),
|
||||
context.read(),
|
||||
),
|
||||
),
|
||||
BlocProvider(
|
||||
create: (context) => SavedViewCubit(
|
||||
context.read<SavedViewRepository>(),
|
||||
context.read(),
|
||||
),
|
||||
),
|
||||
],
|
||||
@@ -210,7 +230,28 @@ class _HomePageState extends State<HomePage> {
|
||||
value: _scannerCubit,
|
||||
child: const ScannerPage(),
|
||||
),
|
||||
const LabelsPage(),
|
||||
MultiBlocProvider(
|
||||
providers: [
|
||||
BlocProvider(
|
||||
create: (context) => LabelCubit<Correspondent>(context.read()),
|
||||
),
|
||||
BlocProvider(
|
||||
create: (context) => LabelCubit<DocumentType>(context.read()),
|
||||
),
|
||||
BlocProvider(
|
||||
create: (context) => LabelCubit<Tag>(context.read()),
|
||||
),
|
||||
BlocProvider(
|
||||
create: (context) => LabelCubit<StoragePath>(context.read()),
|
||||
),
|
||||
],
|
||||
child: const LabelsPage(),
|
||||
),
|
||||
BlocProvider.value(
|
||||
value: _inboxCubit,
|
||||
child: const InboxPage(),
|
||||
),
|
||||
// const SettingsPage(),
|
||||
];
|
||||
return MultiBlocListener(
|
||||
listeners: [
|
||||
@@ -237,8 +278,6 @@ class _HomePageState extends State<HomePage> {
|
||||
builder: (context, sizingInformation) {
|
||||
if (!sizingInformation.isMobile) {
|
||||
return Scaffold(
|
||||
key: rootScaffoldKey,
|
||||
drawer: const AppDrawer(),
|
||||
body: Row(
|
||||
children: [
|
||||
NavigationRail(
|
||||
@@ -258,15 +297,14 @@ class _HomePageState extends State<HomePage> {
|
||||
);
|
||||
}
|
||||
return Scaffold(
|
||||
key: rootScaffoldKey,
|
||||
bottomNavigationBar: NavigationBar(
|
||||
labelBehavior: NavigationDestinationLabelBehavior.alwaysShow,
|
||||
elevation: 4.0,
|
||||
selectedIndex: _currentIndex,
|
||||
onDestinationSelected: _onNavigationChanged,
|
||||
destinations:
|
||||
destinations.map((e) => e.toNavigationDestination()).toList(),
|
||||
),
|
||||
drawer: const AppDrawer(),
|
||||
body: routes[_currentIndex],
|
||||
);
|
||||
},
|
||||
@@ -282,16 +320,10 @@ class _HomePageState extends State<HomePage> {
|
||||
|
||||
void _initializeData(BuildContext context) {
|
||||
try {
|
||||
context.read<LabelRepository<Tag, TagRepositoryState>>().findAll();
|
||||
context
|
||||
.read<LabelRepository<Correspondent, CorrespondentRepositoryState>>()
|
||||
.findAll();
|
||||
context
|
||||
.read<LabelRepository<DocumentType, DocumentTypeRepositoryState>>()
|
||||
.findAll();
|
||||
context
|
||||
.read<LabelRepository<StoragePath, StoragePathRepositoryState>>()
|
||||
.findAll();
|
||||
context.read<LabelRepository<Tag>>().findAll();
|
||||
context.read<LabelRepository<Correspondent>>().findAll();
|
||||
context.read<LabelRepository<DocumentType>>().findAll();
|
||||
context.read<LabelRepository<StoragePath>>().findAll();
|
||||
context.read<SavedViewRepository>().findAll();
|
||||
context.read<PaperlessServerInformationCubit>().updateInformtion();
|
||||
} on PaperlessServerException catch (error, stackTrace) {
|
||||
|
||||
@@ -4,18 +4,20 @@ class RouteDescription {
|
||||
final String label;
|
||||
final Icon icon;
|
||||
final Icon selectedIcon;
|
||||
final Widget Function(Widget icon)? badgeBuilder;
|
||||
|
||||
RouteDescription({
|
||||
required this.label,
|
||||
required this.icon,
|
||||
required this.selectedIcon,
|
||||
this.badgeBuilder,
|
||||
});
|
||||
|
||||
NavigationDestination toNavigationDestination() {
|
||||
return NavigationDestination(
|
||||
label: label,
|
||||
icon: icon,
|
||||
selectedIcon: selectedIcon,
|
||||
icon: badgeBuilder?.call(icon) ?? icon,
|
||||
selectedIcon: badgeBuilder?.call(selectedIcon) ?? selectedIcon,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -30,8 +32,8 @@ class RouteDescription {
|
||||
BottomNavigationBarItem toBottomNavigationBarItem() {
|
||||
return BottomNavigationBarItem(
|
||||
label: label,
|
||||
icon: icon,
|
||||
activeIcon: selectedIcon,
|
||||
icon: badgeBuilder?.call(icon) ?? icon,
|
||||
activeIcon: badgeBuilder?.call(selectedIcon) ?? selectedIcon,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user