mirror of
https://github.com/Xevion/paperless-mobile.git
synced 2025-12-08 12:07:54 -06:00
Merge branch 'main' into bugfix/ios-delete-temporary-directory
This commit is contained in:
@@ -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,
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user