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