Merge branch 'main' into bugfix/ios-delete-temporary-directory

This commit is contained in:
Anton Stubenbord
2023-02-06 17:41:08 +01:00
committed by GitHub
219 changed files with 7582 additions and 3868 deletions

1
.gitignore vendored
View File

@@ -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/

View File

@@ -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.

View File

@@ -68,11 +68,11 @@ android {
storePassword keystoreProperties['storePassword']
}
}
buildTypes {
release {
signingConfig signingConfigs.release
}
}
buildTypes {
release {
signingConfig signingConfigs.release
}
}
}

View File

@@ -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

View File

@@ -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 = (
);

View File

@@ -65,5 +65,7 @@
<false/>
<key>CADisableMinimumFrameDurationOnPhone</key>
<true/>
</dict>
<key>UIApplicationSupportsIndirectInputEvents</key>
<true/>
</dict>
</plist>

7
lib/constants.dart Normal file
View 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;

View File

@@ -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> {

View File

@@ -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,
});
}

View 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();
}
}

View File

@@ -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 {

View File

@@ -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();
}
}

View File

@@ -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();
}
}

View File

@@ -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();
}
}

View File

@@ -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();
}
}

View File

@@ -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();
}
}

View File

@@ -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);
}

View File

@@ -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,

View File

@@ -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);
}

View File

@@ -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,

View File

@@ -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,
};

View File

@@ -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,

View File

@@ -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,
};

View File

@@ -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,

View File

@@ -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,
};

View File

@@ -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,

View File

@@ -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,
};

View File

@@ -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,

View File

@@ -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,
};

View 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,
});
}

View File

@@ -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,
});
}

View File

@@ -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,

View File

@@ -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 {

View File

@@ -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();
}
}

View File

@@ -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;
}
}

View 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 '';
}
}

View File

@@ -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;

View 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;
// }
// }

View File

@@ -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,
],
);
}
}

View File

@@ -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(),

View 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,
),
),
),
);
}
}

View 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!,
]),
),
),
),
);
}
}

View File

@@ -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,
),
);
}

View 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,
);
}
}

View File

@@ -1,5 +0,0 @@
extension DateComparisons on DateTime {
bool isEqualToIgnoringDate(DateTime other) {
return day == other.day && month == other.month && year == other.year;
}
}

View File

@@ -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 {

View File

@@ -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,
);
}
}

View File

@@ -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;
}
}

View 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.')
],
),
);
}
}

View File

@@ -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")),
],
);
}
}

View File

@@ -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();
}
}

View File

@@ -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 {

View File

@@ -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);
}
}
}
}

View File

@@ -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();
}
}

View File

@@ -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);
}

View File

@@ -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,
};

View 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();
}
}

View File

@@ -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,

View File

@@ -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:

View File

@@ -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();
}
}

View File

@@ -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()))

View File

@@ -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)

View File

@@ -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);
}

View 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,
);
},
);
}
}

View File

@@ -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!,
),
],
),
),
),
),
],
),
),
);
}
}

View File

@@ -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(

View File

@@ -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!,
),
],
),
),
),
);
}
}

View File

@@ -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);
}
}
}

View 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,
});
}

View File

@@ -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);
}
}
}

View File

@@ -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,
);
},
);
}
}

View File

@@ -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);
}
}
}

View File

@@ -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());
}
}

View File

@@ -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 [],
);
}
}

View File

@@ -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,
});
}

View File

@@ -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),
),
);
}
}

View File

@@ -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,
);
}
}

View File

@@ -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,
);
},
);
}
}

View File

@@ -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);
}
}
}

View File

@@ -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;
}
}
}

View File

@@ -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();
});
}
}

View File

@@ -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,
),
),
),
);
},
),
),
),
),
),
),
),
);
},
);
},
);
}
}

View 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()),
);
}
},
)
],
);
}
}

View File

@@ -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();
}
}

View File

@@ -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(

View File

@@ -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,

View File

@@ -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,

View File

@@ -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),

View File

@@ -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),

View File

@@ -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),

View File

@@ -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),

View File

@@ -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,

View File

@@ -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,

View File

@@ -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,

View File

@@ -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,

View File

@@ -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);

View File

@@ -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) {

View File

@@ -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