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

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

View File

@@ -1,7 +1,6 @@
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:paperless_api/paperless_api.dart';
import 'package:paperless_mobile/core/bloc/paperless_server_information_state.dart';
import 'package:paperless_mobile/core/security/session_manager.dart';
class PaperlessServerInformationCubit
extends Cubit<PaperlessServerInformationState> {

View File

@@ -1,11 +0,0 @@
import 'package:paperless_api/paperless_api.dart';
class PaperlessStatisticsState {
final bool isLoaded;
final PaperlessServerStatisticsModel? statistics;
PaperlessStatisticsState({
required this.isLoaded,
this.statistics,
});
}

View File

@@ -0,0 +1,54 @@
import 'dart:async';
import 'dart:developer';
import 'package:flutter/foundation.dart';
import 'package:paperless_api/paperless_api.dart';
import 'package:rxdart/subjects.dart';
typedef DocumentChangedCallback = void Function(DocumentModel document);
class DocumentChangedNotifier {
final Subject<DocumentModel> _updated = PublishSubject();
final Subject<DocumentModel> _deleted = PublishSubject();
final Map<dynamic, List<StreamSubscription>> _subscribers = {};
void notifyUpdated(DocumentModel updated) {
debugPrint("Notifying updated document ${updated.id}");
_updated.add(updated);
}
void notifyDeleted(DocumentModel deleted) {
debugPrint("Notifying deleted document ${deleted.id}");
_deleted.add(deleted);
}
void subscribe(
dynamic subscriber, {
DocumentChangedCallback? onUpdated,
DocumentChangedCallback? onDeleted,
}) {
_subscribers.putIfAbsent(
subscriber,
() => [
_updated.listen((value) {
onUpdated?.call(value);
}),
_deleted.listen((value) {
onDeleted?.call(value);
}),
],
);
}
void unsubscribe(dynamic subscriber) {
_subscribers[subscriber]?.forEach((element) {
element.cancel();
});
}
void close() {
_updated.close();
_deleted.close();
}
}

View File

@@ -1,30 +1,30 @@
import 'package:hydrated_bloc/hydrated_bloc.dart';
import 'package:paperless_mobile/core/repository/state/repository_state.dart';
import 'package:paperless_mobile/core/repository/state/indexed_repository_state.dart';
import 'package:rxdart/subjects.dart';
///
/// Base repository class which all repositories should implement
///
abstract class BaseRepository<State extends RepositoryState, Type>
extends Cubit<State> with HydratedMixin {
final State _initialState;
abstract class BaseRepository<T> extends Cubit<IndexedRepositoryState<T>>
with HydratedMixin {
final IndexedRepositoryState<T> _initialState;
BaseRepository(this._initialState) : super(_initialState) {
hydrate();
}
Stream<State?> get values =>
Stream<IndexedRepositoryState<T>?> get values =>
BehaviorSubject.seeded(state)..addStream(super.stream);
State? get current => state;
IndexedRepositoryState<T>? get current => state;
bool get isInitialized => state.hasLoaded;
Future<Type> create(Type object);
Future<Type?> find(int id);
Future<Iterable<Type>> findAll([Iterable<int>? ids]);
Future<Type> update(Type object);
Future<int> delete(Type object);
Future<T> create(T object);
Future<T?> find(int id);
Future<Iterable<T>> findAll([Iterable<int>? ids]);
Future<T> update(T object);
Future<int> delete(T object);
@override
Future<void> clear() async {

View File

@@ -3,10 +3,8 @@ import 'dart:async';
import 'package:paperless_api/paperless_api.dart';
import 'package:paperless_mobile/core/repository/label_repository.dart';
import 'package:paperless_mobile/core/repository/state/impl/correspondent_repository_state.dart';
import 'package:paperless_mobile/core/repository/state/repository_state.dart';
class CorrespondentRepositoryImpl
extends LabelRepository<Correspondent, CorrespondentRepositoryState> {
class CorrespondentRepositoryImpl extends LabelRepository<Correspondent> {
final PaperlessLabelsApi _api;
CorrespondentRepositoryImpl(this._api)
@@ -15,7 +13,7 @@ class CorrespondentRepositoryImpl
@override
Future<Correspondent> create(Correspondent correspondent) async {
final created = await _api.saveCorrespondent(correspondent);
final updatedState = {...state.values}
final updatedState = {...state.values ?? {}}
..putIfAbsent(created.id!, () => created);
emit(CorrespondentRepositoryState(values: updatedState, hasLoaded: true));
return created;
@@ -24,7 +22,7 @@ class CorrespondentRepositoryImpl
@override
Future<int> delete(Correspondent correspondent) async {
await _api.deleteCorrespondent(correspondent);
final updatedState = {...state.values}
final updatedState = {...state.values ?? {}}
..removeWhere((k, v) => k == correspondent.id);
emit(CorrespondentRepositoryState(values: updatedState, hasLoaded: true));
return correspondent.id!;
@@ -34,7 +32,7 @@ class CorrespondentRepositoryImpl
Future<Correspondent?> find(int id) async {
final correspondent = await _api.getCorrespondent(id);
if (correspondent != null) {
final updatedState = {...state.values}..[id] = correspondent;
final updatedState = {...state.values ?? {}}..[id] = correspondent;
emit(CorrespondentRepositoryState(values: updatedState, hasLoaded: true));
return correspondent;
}
@@ -44,7 +42,7 @@ class CorrespondentRepositoryImpl
@override
Future<Iterable<Correspondent>> findAll([Iterable<int>? ids]) async {
final correspondents = await _api.getCorrespondents(ids);
final updatedState = {...state.values}
final updatedState = {...state.values ?? {}}
..addEntries(correspondents.map((e) => MapEntry(e.id!, e)));
emit(CorrespondentRepositoryState(values: updatedState, hasLoaded: true));
return correspondents;
@@ -53,7 +51,8 @@ class CorrespondentRepositoryImpl
@override
Future<Correspondent> update(Correspondent correspondent) async {
final updated = await _api.updateCorrespondent(correspondent);
final updatedState = {...state.values}..update(updated.id!, (_) => updated);
final updatedState = {...state.values ?? {}}
..update(updated.id!, (_) => updated);
emit(CorrespondentRepositoryState(values: updatedState, hasLoaded: true));
return updated;
}
@@ -64,7 +63,7 @@ class CorrespondentRepositoryImpl
}
@override
Map<String, dynamic> toJson(CorrespondentRepositoryState state) {
Map<String, dynamic> toJson(covariant CorrespondentRepositoryState state) {
return state.toJson();
}
}

View File

@@ -1,10 +1,8 @@
import 'package:paperless_api/paperless_api.dart';
import 'package:paperless_mobile/core/repository/label_repository.dart';
import 'package:paperless_mobile/core/repository/state/impl/document_type_repository_state.dart';
import 'package:rxdart/rxdart.dart' show BehaviorSubject;
class DocumentTypeRepositoryImpl
extends LabelRepository<DocumentType, DocumentTypeRepositoryState> {
class DocumentTypeRepositoryImpl extends LabelRepository<DocumentType> {
final PaperlessLabelsApi _api;
DocumentTypeRepositoryImpl(this._api)
@@ -13,7 +11,7 @@ class DocumentTypeRepositoryImpl
@override
Future<DocumentType> create(DocumentType documentType) async {
final created = await _api.saveDocumentType(documentType);
final updatedState = {...state.values}
final updatedState = {...state.values ?? {}}
..putIfAbsent(created.id!, () => created);
emit(DocumentTypeRepositoryState(values: updatedState, hasLoaded: true));
return created;
@@ -22,7 +20,7 @@ class DocumentTypeRepositoryImpl
@override
Future<int> delete(DocumentType documentType) async {
await _api.deleteDocumentType(documentType);
final updatedState = {...state.values}
final updatedState = {...state.values ?? {}}
..removeWhere((k, v) => k == documentType.id);
emit(DocumentTypeRepositoryState(values: updatedState, hasLoaded: true));
return documentType.id!;
@@ -32,7 +30,7 @@ class DocumentTypeRepositoryImpl
Future<DocumentType?> find(int id) async {
final documentType = await _api.getDocumentType(id);
if (documentType != null) {
final updatedState = {...state.values}..[id] = documentType;
final updatedState = {...state.values ?? {}}..[id] = documentType;
emit(DocumentTypeRepositoryState(values: updatedState, hasLoaded: true));
return documentType;
}
@@ -42,7 +40,7 @@ class DocumentTypeRepositoryImpl
@override
Future<Iterable<DocumentType>> findAll([Iterable<int>? ids]) async {
final documentTypes = await _api.getDocumentTypes(ids);
final updatedState = {...state.values}
final updatedState = {...state.values ?? {}}
..addEntries(documentTypes.map((e) => MapEntry(e.id!, e)));
emit(DocumentTypeRepositoryState(values: updatedState, hasLoaded: true));
return documentTypes;
@@ -51,7 +49,8 @@ class DocumentTypeRepositoryImpl
@override
Future<DocumentType> update(DocumentType documentType) async {
final updated = await _api.updateDocumentType(documentType);
final updatedState = {...state.values}..update(updated.id!, (_) => updated);
final updatedState = {...state.values ?? {}}
..update(updated.id!, (_) => updated);
emit(DocumentTypeRepositoryState(values: updatedState, hasLoaded: true));
return updated;
}
@@ -62,7 +61,7 @@ class DocumentTypeRepositoryImpl
}
@override
Map<String, dynamic> toJson(DocumentTypeRepositoryState state) {
Map<String, dynamic> toJson(covariant DocumentTypeRepositoryState state) {
return state.toJson();
}
}

View File

@@ -10,7 +10,7 @@ class SavedViewRepositoryImpl extends SavedViewRepository {
@override
Future<SavedView> create(SavedView object) async {
final created = await _api.save(object);
final updatedState = {...state.values}
final updatedState = {...state.values ?? {}}
..putIfAbsent(created.id!, () => created);
emit(SavedViewRepositoryState(values: updatedState, hasLoaded: true));
return created;
@@ -19,7 +19,7 @@ class SavedViewRepositoryImpl extends SavedViewRepository {
@override
Future<int> delete(SavedView view) async {
await _api.delete(view);
final updatedState = {...state.values}..remove(view.id);
final updatedState = {...state.values ?? {}}..remove(view.id);
emit(SavedViewRepositoryState(values: updatedState, hasLoaded: true));
return view.id!;
}
@@ -27,7 +27,7 @@ class SavedViewRepositoryImpl extends SavedViewRepository {
@override
Future<SavedView?> find(int id) async {
final found = await _api.find(id);
final updatedState = {...state.values}
final updatedState = {...state.values ?? {}}
..update(id, (_) => found, ifAbsent: () => found);
emit(SavedViewRepositoryState(values: updatedState, hasLoaded: true));
return found;
@@ -37,7 +37,7 @@ class SavedViewRepositoryImpl extends SavedViewRepository {
Future<Iterable<SavedView>> findAll([Iterable<int>? ids]) async {
final found = await _api.findAll(ids);
final updatedState = {
...state.values,
...state.values ?? {},
...{for (final view in found) view.id!: view},
};
emit(SavedViewRepositoryState(values: updatedState, hasLoaded: true));
@@ -56,7 +56,7 @@ class SavedViewRepositoryImpl extends SavedViewRepository {
}
@override
Map<String, dynamic> toJson(SavedViewRepositoryState state) {
Map<String, dynamic> toJson(covariant SavedViewRepositoryState state) {
return state.toJson();
}
}

View File

@@ -3,8 +3,7 @@ import 'package:paperless_mobile/core/repository/label_repository.dart';
import 'package:paperless_mobile/core/repository/state/impl/storage_path_repository_state.dart';
import 'package:rxdart/rxdart.dart' show BehaviorSubject;
class StoragePathRepositoryImpl
extends LabelRepository<StoragePath, StoragePathRepositoryState> {
class StoragePathRepositoryImpl extends LabelRepository<StoragePath> {
final PaperlessLabelsApi _api;
StoragePathRepositoryImpl(this._api)
@@ -13,7 +12,7 @@ class StoragePathRepositoryImpl
@override
Future<StoragePath> create(StoragePath storagePath) async {
final created = await _api.saveStoragePath(storagePath);
final updatedState = {...state.values}
final updatedState = {...state.values ?? {}}
..putIfAbsent(created.id!, () => created);
emit(StoragePathRepositoryState(values: updatedState, hasLoaded: true));
return created;
@@ -22,7 +21,7 @@ class StoragePathRepositoryImpl
@override
Future<int> delete(StoragePath storagePath) async {
await _api.deleteStoragePath(storagePath);
final updatedState = {...state.values}
final updatedState = {...state.values ?? {}}
..removeWhere((k, v) => k == storagePath.id);
emit(StoragePathRepositoryState(values: updatedState, hasLoaded: true));
return storagePath.id!;
@@ -32,7 +31,7 @@ class StoragePathRepositoryImpl
Future<StoragePath?> find(int id) async {
final storagePath = await _api.getStoragePath(id);
if (storagePath != null) {
final updatedState = {...state.values}..[id] = storagePath;
final updatedState = {...state.values ?? {}}..[id] = storagePath;
emit(StoragePathRepositoryState(values: updatedState, hasLoaded: true));
return storagePath;
}
@@ -42,7 +41,7 @@ class StoragePathRepositoryImpl
@override
Future<Iterable<StoragePath>> findAll([Iterable<int>? ids]) async {
final storagePaths = await _api.getStoragePaths(ids);
final updatedState = {...state.values}
final updatedState = {...state.values ?? {}}
..addEntries(storagePaths.map((e) => MapEntry(e.id!, e)));
emit(StoragePathRepositoryState(values: updatedState, hasLoaded: true));
return storagePaths;
@@ -51,7 +50,8 @@ class StoragePathRepositoryImpl
@override
Future<StoragePath> update(StoragePath storagePath) async {
final updated = await _api.updateStoragePath(storagePath);
final updatedState = {...state.values}..update(updated.id!, (_) => updated);
final updatedState = {...state.values ?? {}}
..update(updated.id!, (_) => updated);
emit(StoragePathRepositoryState(values: updatedState, hasLoaded: true));
return updated;
}
@@ -62,7 +62,7 @@ class StoragePathRepositoryImpl
}
@override
Map<String, dynamic> toJson(StoragePathRepositoryState state) {
Map<String, dynamic> toJson(covariant StoragePathRepositoryState state) {
return state.toJson();
}
}

View File

@@ -1,10 +1,8 @@
import 'package:paperless_api/paperless_api.dart';
import 'package:paperless_mobile/core/repository/label_repository.dart';
import 'package:paperless_mobile/core/repository/state/impl/correspondent_repository_state.dart';
import 'package:paperless_mobile/core/repository/state/impl/tag_repository_state.dart';
import 'package:paperless_mobile/core/repository/state/repository_state.dart';
class TagRepositoryImpl extends LabelRepository<Tag, TagRepositoryState> {
class TagRepositoryImpl extends LabelRepository<Tag> {
final PaperlessLabelsApi _api;
TagRepositoryImpl(this._api) : super(const TagRepositoryState());
@@ -12,7 +10,7 @@ class TagRepositoryImpl extends LabelRepository<Tag, TagRepositoryState> {
@override
Future<Tag> create(Tag object) async {
final created = await _api.saveTag(object);
final updatedState = {...state.values}
final updatedState = {...state.values ?? {}}
..putIfAbsent(created.id!, () => created);
emit(TagRepositoryState(values: updatedState, hasLoaded: true));
return created;
@@ -21,7 +19,8 @@ class TagRepositoryImpl extends LabelRepository<Tag, TagRepositoryState> {
@override
Future<int> delete(Tag tag) async {
await _api.deleteTag(tag);
final updatedState = {...state.values}..removeWhere((k, v) => k == tag.id);
final updatedState = {...state.values ?? {}}
..removeWhere((k, v) => k == tag.id);
emit(TagRepositoryState(values: updatedState, hasLoaded: true));
return tag.id!;
}
@@ -30,7 +29,7 @@ class TagRepositoryImpl extends LabelRepository<Tag, TagRepositoryState> {
Future<Tag?> find(int id) async {
final tag = await _api.getTag(id);
if (tag != null) {
final updatedState = {...state.values}..[id] = tag;
final updatedState = {...state.values ?? {}}..[id] = tag;
emit(TagRepositoryState(values: updatedState, hasLoaded: true));
return tag;
}
@@ -40,7 +39,7 @@ class TagRepositoryImpl extends LabelRepository<Tag, TagRepositoryState> {
@override
Future<Iterable<Tag>> findAll([Iterable<int>? ids]) async {
final tags = await _api.getTags(ids);
final updatedState = {...state.values}
final updatedState = {...state.values ?? {}}
..addEntries(tags.map((e) => MapEntry(e.id!, e)));
emit(TagRepositoryState(values: updatedState, hasLoaded: true));
return tags;
@@ -49,7 +48,8 @@ class TagRepositoryImpl extends LabelRepository<Tag, TagRepositoryState> {
@override
Future<Tag> update(Tag tag) async {
final updated = await _api.updateTag(tag);
final updatedState = {...state.values}..update(updated.id!, (_) => updated);
final updatedState = {...state.values ?? {}}
..update(updated.id!, (_) => updated);
emit(TagRepositoryState(values: updatedState, hasLoaded: true));
return updated;
}
@@ -60,7 +60,7 @@ class TagRepositoryImpl extends LabelRepository<Tag, TagRepositoryState> {
}
@override
Map<String, dynamic>? toJson(TagRepositoryState state) {
Map<String, dynamic>? toJson(covariant TagRepositoryState state) {
return state.toJson();
}
}

View File

@@ -1,8 +1,7 @@
import 'package:paperless_api/paperless_api.dart';
import 'package:paperless_mobile/core/repository/base_repository.dart';
import 'package:paperless_mobile/core/repository/state/repository_state.dart';
import 'package:paperless_mobile/core/repository/state/indexed_repository_state.dart';
abstract class LabelRepository<T extends Label, State extends RepositoryState>
extends BaseRepository<State, T> {
LabelRepository(State initial) : super(initial);
abstract class LabelRepository<T extends Label> extends BaseRepository<T> {
LabelRepository(IndexedRepositoryState<T> initial) : super(initial);
}

View File

@@ -17,20 +17,16 @@ class LabelRepositoriesProvider extends StatelessWidget {
return MultiRepositoryProvider(
providers: [
RepositoryProvider(
create: (context) => context.read<
LabelRepository<Correspondent, CorrespondentRepositoryState>>(),
create: (context) => context.read<LabelRepository<Correspondent>>(),
),
RepositoryProvider(
create: (context) => context.read<
LabelRepository<DocumentType, DocumentTypeRepositoryState>>(),
create: (context) => context.read<LabelRepository<DocumentType>>(),
),
RepositoryProvider(
create: (context) => context
.read<LabelRepository<StoragePath, StoragePathRepositoryState>>(),
create: (context) => context.read<LabelRepository<StoragePath>>(),
),
RepositoryProvider(
create: (context) =>
context.read<LabelRepository<Tag, TagRepositoryState>>(),
create: (context) => context.read<LabelRepository<Tag>>(),
),
],
child: child,

View File

@@ -1,8 +1,8 @@
import 'package:paperless_api/paperless_api.dart';
import 'package:paperless_mobile/core/repository/base_repository.dart';
import 'package:paperless_mobile/core/repository/state/impl/saved_view_repository_state.dart';
import 'package:paperless_mobile/core/repository/state/indexed_repository_state.dart';
abstract class SavedViewRepository
extends BaseRepository<SavedViewRepositoryState, SavedView> {
abstract class SavedViewRepository extends BaseRepository<SavedView> {
SavedViewRepository(super.initialState);
}

View File

@@ -1,13 +1,13 @@
import 'package:flutter/foundation.dart';
import 'package:json_annotation/json_annotation.dart';
import 'package:paperless_api/paperless_api.dart';
import 'package:paperless_mobile/core/repository/state/repository_state.dart';
import 'package:paperless_mobile/core/repository/state/indexed_repository_state.dart';
part 'correspondent_repository_state.g.dart';
@JsonSerializable()
class CorrespondentRepositoryState
extends RepositoryState<Map<int, Correspondent>> {
extends IndexedRepositoryState<Correspondent> {
const CorrespondentRepositoryState({
super.values = const {},
super.hasLoaded,

View File

@@ -20,6 +20,6 @@ CorrespondentRepositoryState _$CorrespondentRepositoryStateFromJson(
Map<String, dynamic> _$CorrespondentRepositoryStateToJson(
CorrespondentRepositoryState instance) =>
<String, dynamic>{
'values': instance.values.map((k, e) => MapEntry(k.toString(), e)),
'values': instance.values?.map((k, e) => MapEntry(k.toString(), e)),
'hasLoaded': instance.hasLoaded,
};

View File

@@ -1,20 +1,21 @@
import 'package:paperless_api/paperless_api.dart';
import 'package:paperless_mobile/core/repository/state/repository_state.dart';
import 'package:paperless_mobile/core/repository/state/indexed_repository_state.dart';
import 'package:json_annotation/json_annotation.dart';
part 'document_type_repository_state.g.dart';
@JsonSerializable()
class DocumentTypeRepositoryState
extends RepositoryState<Map<int, DocumentType>> {
class DocumentTypeRepositoryState extends IndexedRepositoryState<DocumentType> {
const DocumentTypeRepositoryState({
super.values = const {},
super.hasLoaded,
});
@override
DocumentTypeRepositoryState copyWith(
{Map<int, DocumentType>? values, bool? hasLoaded}) {
DocumentTypeRepositoryState copyWith({
Map<int, DocumentType>? values,
bool? hasLoaded,
}) {
return DocumentTypeRepositoryState(
values: values ?? this.values,
hasLoaded: hasLoaded ?? this.hasLoaded,

View File

@@ -20,6 +20,6 @@ DocumentTypeRepositoryState _$DocumentTypeRepositoryStateFromJson(
Map<String, dynamic> _$DocumentTypeRepositoryStateToJson(
DocumentTypeRepositoryState instance) =>
<String, dynamic>{
'values': instance.values.map((k, e) => MapEntry(k.toString(), e)),
'values': instance.values?.map((k, e) => MapEntry(k.toString(), e)),
'hasLoaded': instance.hasLoaded,
};

View File

@@ -1,11 +1,11 @@
import 'package:paperless_api/paperless_api.dart';
import 'package:paperless_mobile/core/repository/state/repository_state.dart';
import 'package:paperless_mobile/core/repository/state/indexed_repository_state.dart';
import 'package:json_annotation/json_annotation.dart';
part 'saved_view_repository_state.g.dart';
@JsonSerializable()
class SavedViewRepositoryState extends RepositoryState<Map<int, SavedView>> {
class SavedViewRepositoryState extends IndexedRepositoryState<SavedView> {
const SavedViewRepositoryState({
super.values = const {},
super.hasLoaded = false,

View File

@@ -20,6 +20,6 @@ SavedViewRepositoryState _$SavedViewRepositoryStateFromJson(
Map<String, dynamic> _$SavedViewRepositoryStateToJson(
SavedViewRepositoryState instance) =>
<String, dynamic>{
'values': instance.values.map((k, e) => MapEntry(k.toString(), e)),
'values': instance.values?.map((k, e) => MapEntry(k.toString(), e)),
'hasLoaded': instance.hasLoaded,
};

View File

@@ -1,20 +1,21 @@
import 'package:paperless_api/paperless_api.dart';
import 'package:paperless_mobile/core/repository/state/repository_state.dart';
import 'package:paperless_mobile/core/repository/state/indexed_repository_state.dart';
import 'package:json_annotation/json_annotation.dart';
part 'storage_path_repository_state.g.dart';
@JsonSerializable()
class StoragePathRepositoryState
extends RepositoryState<Map<int, StoragePath>> {
class StoragePathRepositoryState extends IndexedRepositoryState<StoragePath> {
const StoragePathRepositoryState({
super.values = const {},
super.hasLoaded = false,
});
@override
StoragePathRepositoryState copyWith(
{Map<int, StoragePath>? values, bool? hasLoaded}) {
StoragePathRepositoryState copyWith({
Map<int, StoragePath>? values,
bool? hasLoaded,
}) {
return StoragePathRepositoryState(
values: values ?? this.values,
hasLoaded: hasLoaded ?? this.hasLoaded,

View File

@@ -20,6 +20,6 @@ StoragePathRepositoryState _$StoragePathRepositoryStateFromJson(
Map<String, dynamic> _$StoragePathRepositoryStateToJson(
StoragePathRepositoryState instance) =>
<String, dynamic>{
'values': instance.values.map((k, e) => MapEntry(k.toString(), e)),
'values': instance.values?.map((k, e) => MapEntry(k.toString(), e)),
'hasLoaded': instance.hasLoaded,
};

View File

@@ -1,18 +1,21 @@
import 'package:json_annotation/json_annotation.dart';
import 'package:paperless_api/paperless_api.dart';
import 'package:paperless_mobile/core/repository/state/repository_state.dart';
import 'package:paperless_mobile/core/repository/state/indexed_repository_state.dart';
part 'tag_repository_state.g.dart';
@JsonSerializable()
class TagRepositoryState extends RepositoryState<Map<int, Tag>> {
class TagRepositoryState extends IndexedRepositoryState<Tag> {
const TagRepositoryState({
super.values = const {},
super.hasLoaded = false,
});
@override
TagRepositoryState copyWith({Map<int, Tag>? values, bool? hasLoaded}) {
TagRepositoryState copyWith({
Map<int, Tag>? values,
bool? hasLoaded,
}) {
return TagRepositoryState(
values: values ?? this.values,
hasLoaded: hasLoaded ?? this.hasLoaded,

View File

@@ -18,6 +18,6 @@ TagRepositoryState _$TagRepositoryStateFromJson(Map<String, dynamic> json) =>
Map<String, dynamic> _$TagRepositoryStateToJson(TagRepositoryState instance) =>
<String, dynamic>{
'values': instance.values.map((k, e) => MapEntry(k.toString(), e)),
'values': instance.values?.map((k, e) => MapEntry(k.toString(), e)),
'hasLoaded': instance.hasLoaded,
};

View File

@@ -0,0 +1,16 @@
abstract class IndexedRepositoryState<T> {
final Map<int, T>? values;
final bool hasLoaded;
const IndexedRepositoryState({
required this.values,
this.hasLoaded = false,
}) : assert(!(values == null) || !hasLoaded);
IndexedRepositoryState.loaded(this.values) : hasLoaded = true;
IndexedRepositoryState<T> copyWith({
Map<int, T>? values,
bool? hasLoaded,
});
}

View File

@@ -1,16 +0,0 @@
abstract class RepositoryState<T> {
final T values;
final bool hasLoaded;
const RepositoryState({
required this.values,
this.hasLoaded = false,
});
RepositoryState.loaded(this.values) : hasLoaded = true;
RepositoryState<T> copyWith({
T? values,
bool? hasLoaded,
});
}

View File

@@ -27,7 +27,7 @@ class GithubIssueService {
..tryPutIfAbsent('assignees', () => assignees?.join(','))
..tryPutIfAbsent('project', () => project),
);
log("[GitHubIssueService] Creating GitHub issue: " + uri.toString());
debugPrint("[GitHubIssueService] Creating GitHub issue: " + uri.toString());
launchUrl(
uri,
mode: LaunchMode.externalApplication,

View File

@@ -8,7 +8,7 @@ import 'package:paperless_api/paperless_api.dart';
import 'package:paperless_mobile/core/bloc/document_status_cubit.dart';
import 'package:paperless_mobile/core/model/document_processing_status.dart';
import 'package:paperless_mobile/features/login/model/authentication_information.dart';
import 'package:paperless_mobile/util.dart';
import 'package:paperless_mobile/constants.dart';
import 'package:web_socket_channel/io.dart';
abstract class StatusService {

View File

@@ -1,77 +0,0 @@
import 'dart:convert';
import 'package:encrypted_shared_preferences/encrypted_shared_preferences.dart';
import 'package:flutter/foundation.dart';
import 'package:paperless_mobile/core/type/types.dart';
import 'package:paperless_mobile/features/login/model/authentication_information.dart';
import 'package:paperless_mobile/features/login/model/client_certificate.dart';
import 'package:paperless_mobile/features/settings/model/application_settings_state.dart';
abstract class LocalVault {
Future<void> storeAuthenticationInformation(AuthenticationInformation auth);
Future<AuthenticationInformation?> loadAuthenticationInformation();
Future<ClientCertificate?> loadCertificate();
Future<bool> storeApplicationSettings(ApplicationSettingsState settings);
Future<ApplicationSettingsState?> loadApplicationSettings();
Future<void> clear();
}
class LocalVaultImpl implements LocalVault {
static const applicationSettingsKey = "applicationSettings";
static const authenticationKey = "authentication";
final EncryptedSharedPreferences sharedPreferences;
LocalVaultImpl(this.sharedPreferences);
@override
Future<void> storeAuthenticationInformation(
AuthenticationInformation auth,
) async {
await sharedPreferences.setString(
authenticationKey,
jsonEncode(auth.toJson()),
);
}
@override
Future<AuthenticationInformation?> loadAuthenticationInformation() async {
if ((await sharedPreferences.getString(authenticationKey)).isEmpty) {
return null;
}
return AuthenticationInformation.fromJson(
jsonDecode(await sharedPreferences.getString(authenticationKey)),
);
}
@override
Future<ClientCertificate?> loadCertificate() async {
return loadAuthenticationInformation()
.then((value) => value?.clientCertificate);
}
@override
Future<bool> storeApplicationSettings(ApplicationSettingsState settings) {
return sharedPreferences.setString(
applicationSettingsKey,
jsonEncode(settings.toJson()),
);
}
@override
Future<ApplicationSettingsState?> loadApplicationSettings() async {
final settings = await sharedPreferences.getString(applicationSettingsKey);
if (settings.isEmpty) {
return null;
}
return compute(
ApplicationSettingsState.fromJson,
jsonDecode(settings) as JSON,
);
}
@override
Future<void> clear() {
return sharedPreferences.clear();
}
}

View File

@@ -0,0 +1,13 @@
import 'package:flutter/material.dart';
import 'package:paperless_mobile/features/settings/model/color_scheme_option.dart';
import 'package:paperless_mobile/generated/l10n.dart';
String translateColorSchemeOption(
BuildContext context, ColorSchemeOption option) {
switch (option) {
case ColorSchemeOption.classic:
return S.of(context).colorSchemeOptionClassic;
case ColorSchemeOption.dynamic:
return S.of(context).colorSchemeOptionDynamic;
}
}

View File

@@ -0,0 +1,24 @@
import 'package:flutter/material.dart';
import 'package:paperless_api/paperless_api.dart';
import 'package:paperless_mobile/generated/l10n.dart';
String translateSortField(BuildContext context, SortField? sortField) {
switch (sortField) {
case SortField.archiveSerialNumber:
return S.of(context).documentArchiveSerialNumberPropertyShortLabel;
case SortField.correspondentName:
return S.of(context).documentCorrespondentPropertyLabel;
case SortField.title:
return S.of(context).documentTitlePropertyLabel;
case SortField.documentType:
return S.of(context).documentDocumentTypePropertyLabel;
case SortField.created:
return S.of(context).documentCreatedPropertyLabel;
case SortField.added:
return S.of(context).documentAddedPropertyLabel;
case SortField.modified:
return S.of(context).documentModifiedPropertyLabel;
default:
return '';
}
}

View File

@@ -1,3 +1,6 @@
import 'package:paperless_api/paperless_api.dart';
import 'package:rxdart/subjects.dart';
typedef JSON = Map<String, dynamic>;
typedef PaperlessValidationErrors = Map<String, String>;
typedef PaperlessLocalizedErrorMessage = String;

View File

@@ -0,0 +1,217 @@
// import 'package:flutter/material.dart';
// import 'package:flutter_bloc/flutter_bloc.dart';
// import 'package:paperless_mobile/constants.dart';
// import 'package:paperless_mobile/features/settings/bloc/application_settings_cubit.dart';
// import 'package:paperless_mobile/features/settings/bloc/application_settings_state.dart';
// import 'package:paperless_mobile/features/settings/model/view_type.dart';
// import 'package:paperless_mobile/features/settings/view/settings_page.dart';
// import 'package:paperless_mobile/generated/l10n.dart';
// import 'package:url_launcher/link.dart';
// import 'package:url_launcher/url_launcher_string.dart';
// /// Declares selectable actions in menu.
// enum AppPopupMenuEntries {
// // Documents preview
// documentsSelectListView,
// documentsSelectGridView,
// // Generic actions
// openAboutThisAppDialog,
// reportBug,
// openSettings,
// // Adds a divider
// divider;
// }
// class AppOptionsPopupMenu extends StatelessWidget {
// final List<AppPopupMenuEntries> displayedActions;
// const AppOptionsPopupMenu({
// super.key,
// required this.displayedActions,
// });
// @override
// Widget build(BuildContext context) {
// return PopupMenuButton<AppPopupMenuEntries>(
// position: PopupMenuPosition.under,
// icon: const Icon(Icons.more_vert),
// onSelected: (action) {
// switch (action) {
// case AppPopupMenuEntries.documentsSelectListView:
// context.read<ApplicationSettingsCubit>().setViewType(ViewType.list);
// break;
// case AppPopupMenuEntries.documentsSelectGridView:
// context.read<ApplicationSettingsCubit>().setViewType(ViewType.grid);
// break;
// case AppPopupMenuEntries.openAboutThisAppDialog:
// _showAboutDialog(context);
// break;
// case AppPopupMenuEntries.openSettings:
// Navigator.of(context).push(
// MaterialPageRoute(
// builder: (context) => BlocProvider.value(
// value: context.read<ApplicationSettingsCubit>(),
// child: const SettingsPage(),
// ),
// ),
// );
// break;
// case AppPopupMenuEntries.reportBug:
// launchUrlString(
// 'https://github.com/astubenbord/paperless-mobile/issues/new',
// );
// break;
// default:
// break;
// }
// },
// itemBuilder: _buildEntries,
// );
// }
// PopupMenuItem<AppPopupMenuEntries> _buildReportBugTile(BuildContext context) {
// return PopupMenuItem(
// value: AppPopupMenuEntries.reportBug,
// padding: EdgeInsets.zero,
// child: ListTile(
// leading: const Icon(Icons.bug_report),
// title: Text(S.of(context).appDrawerReportBugLabel),
// ),
// );
// }
// PopupMenuItem<AppPopupMenuEntries> _buildSettingsTile(BuildContext context) {
// return PopupMenuItem(
// padding: EdgeInsets.zero,
// value: AppPopupMenuEntries.openSettings,
// child: ListTile(
// leading: const Icon(Icons.settings_outlined),
// title: Text(S.of(context).appDrawerSettingsLabel),
// ),
// );
// }
// PopupMenuItem<AppPopupMenuEntries> _buildAboutTile(BuildContext context) {
// return PopupMenuItem(
// padding: EdgeInsets.zero,
// value: AppPopupMenuEntries.openAboutThisAppDialog,
// child: ListTile(
// leading: const Icon(Icons.info_outline),
// title: Text(S.of(context).appDrawerAboutLabel),
// ),
// );
// }
// PopupMenuItem<AppPopupMenuEntries> _buildListViewTile() {
// return PopupMenuItem(
// padding: EdgeInsets.zero,
// child: BlocBuilder<ApplicationSettingsCubit, ApplicationSettingsState>(
// builder: (context, state) {
// return ListTile(
// leading: const Icon(Icons.list),
// title: const Text("List"),
// trailing: state.preferredViewType == ViewType.list
// ? const Icon(Icons.check)
// : null,
// );
// },
// ),
// value: AppPopupMenuEntries.documentsSelectListView,
// );
// }
// PopupMenuItem<AppPopupMenuEntries> _buildGridViewTile() {
// return PopupMenuItem(
// value: AppPopupMenuEntries.documentsSelectGridView,
// padding: EdgeInsets.zero,
// child: BlocBuilder<ApplicationSettingsCubit, ApplicationSettingsState>(
// builder: (context, state) {
// return ListTile(
// leading: const Icon(Icons.grid_view_rounded),
// title: const Text("Grid"),
// trailing: state.preferredViewType == ViewType.grid
// ? const Icon(Icons.check)
// : null,
// );
// },
// ),
// );
// }
// void _showAboutDialog(BuildContext context) {
// showAboutDialog(
// context: context,
// applicationIcon: const ImageIcon(
// AssetImage('assets/logos/paperless_logo_green.png'),
// ),
// applicationName: 'Paperless Mobile',
// applicationVersion: packageInfo.version + '+' + packageInfo.buildNumber,
// children: [
// Text(S.of(context).aboutDialogDevelopedByText('Anton Stubenbord')),
// Link(
// uri: Uri.parse('https://github.com/astubenbord/paperless-mobile'),
// builder: (context, followLink) => GestureDetector(
// onTap: followLink,
// child: Text(
// 'https://github.com/astubenbord/paperless-mobile',
// style: TextStyle(color: Theme.of(context).colorScheme.tertiary),
// ),
// ),
// ),
// const SizedBox(height: 16),
// Text(
// 'Credits',
// style: Theme.of(context).textTheme.titleMedium,
// ),
// _buildOnboardingImageCredits(),
// ],
// );
// }
// Widget _buildOnboardingImageCredits() {
// return Link(
// uri: Uri.parse(
// 'https://www.freepik.com/free-vector/business-team-working-cogwheel-mechanism-together_8270974.htm#query=setting&position=4&from_view=author'),
// builder: (context, followLink) => Wrap(
// children: [
// const Text('Onboarding images by '),
// GestureDetector(
// onTap: followLink,
// child: Text(
// 'pch.vector',
// style: TextStyle(color: Theme.of(context).colorScheme.tertiary),
// ),
// ),
// const Text(' on Freepik.')
// ],
// ),
// );
// }
// List<PopupMenuEntry<AppPopupMenuEntries>> _buildEntries(
// BuildContext context) {
// List<PopupMenuEntry<AppPopupMenuEntries>> items = [];
// for (final entry in displayedActions) {
// switch (entry) {
// case AppPopupMenuEntries.documentsSelectListView:
// items.add(_buildListViewTile());
// break;
// case AppPopupMenuEntries.documentsSelectGridView:
// items.add(_buildGridViewTile());
// break;
// case AppPopupMenuEntries.openAboutThisAppDialog:
// items.add(_buildAboutTile(context));
// break;
// case AppPopupMenuEntries.reportBug:
// items.add(_buildReportBugTile(context));
// break;
// case AppPopupMenuEntries.openSettings:
// items.add(_buildSettingsTile(context));
// break;
// case AppPopupMenuEntries.divider:
// items.add(const PopupMenuDivider());
// break;
// }
// }
// return items;
// }
// }

View File

@@ -1,88 +0,0 @@
import 'dart:math';
import 'package:flutter/material.dart';
import 'package:paperless_mobile/extensions/flutter_extensions.dart';
import 'package:shimmer/shimmer.dart';
class DocumentsListLoadingWidget extends StatelessWidget {
final List<Widget> above;
final List<Widget> below;
static const tags = [" ", " ", " "];
static const titleLengths = <double>[double.infinity, 150.0, 200.0];
static const correspondentLengths = <double>[200.0, 300.0, 150.0];
static const fontSize = 16.0;
const DocumentsListLoadingWidget({
super.key,
this.above = const [],
this.below = const [],
});
@override
Widget build(BuildContext context) {
return ListView(
children: <Widget>[
...above,
...List.generate(25, (idx) {
final r = Random(idx);
final tagCount = r.nextInt(tags.length + 1);
final correspondentLength =
correspondentLengths[r.nextInt(correspondentLengths.length - 1)];
final titleLength = titleLengths[r.nextInt(titleLengths.length - 1)];
return Shimmer.fromColors(
baseColor: Theme.of(context).brightness == Brightness.light
? Colors.grey[300]!
: Colors.grey[900]!,
highlightColor: Theme.of(context).brightness == Brightness.light
? Colors.grey[100]!
: Colors.grey[600]!,
child: ListTile(
contentPadding: const EdgeInsets.all(8),
dense: true,
isThreeLine: true,
leading: ClipRRect(
borderRadius: BorderRadius.circular(8),
child: Container(
color: Colors.white,
height: 50,
width: 35,
),
),
title: Container(
padding: const EdgeInsets.symmetric(vertical: 2.0),
width: correspondentLength,
height: fontSize,
color: Colors.white,
),
subtitle: Padding(
padding: const EdgeInsets.symmetric(vertical: 2.0),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisAlignment: MainAxisAlignment.spaceAround,
children: [
Container(
padding: const EdgeInsets.symmetric(vertical: 2.0),
height: fontSize,
width: titleLength,
color: Colors.white,
),
Wrap(
spacing: 2.0,
children: List.generate(
tagCount,
(index) => InputChip(
label: Text(tags[r.nextInt(tags.length)]),
),
),
).paddedOnly(top: 4),
],
),
),
),
);
}).toList(),
...below,
],
);
}
}

View File

@@ -6,6 +6,7 @@ import 'package:paperless_mobile/generated/l10n.dart';
class HintCard extends StatelessWidget {
final String hintText;
final double elevation;
final IconData hintIcon;
final VoidCallback? onHintAcknowledged;
final bool show;
const HintCard({
@@ -13,7 +14,8 @@ class HintCard extends StatelessWidget {
required this.hintText,
this.onHintAcknowledged,
this.elevation = 1,
required this.show,
this.show = true,
this.hintIcon = Icons.tips_and_updates_outlined,
});
@override
@@ -31,16 +33,19 @@ class HintCard extends StatelessWidget {
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Icon(
Icons.tips_and_updates_outlined,
hintIcon,
color: Theme.of(context).hintColor,
).padded(),
Align(
alignment: Alignment.center,
child: Text(
hintText,
softWrap: true,
textAlign: TextAlign.center,
style: Theme.of(context).textTheme.bodySmall,
Padding(
padding: const EdgeInsets.all(8.0),
child: Align(
alignment: Alignment.center,
child: Text(
hintText,
softWrap: true,
textAlign: TextAlign.center,
style: Theme.of(context).textTheme.bodySmall,
),
),
),
if (onHintAcknowledged != null)
@@ -52,7 +57,7 @@ class HintCard extends StatelessWidget {
),
)
else
Padding(padding: EdgeInsets.only(bottom: 24)),
const Padding(padding: EdgeInsets.only(bottom: 24)),
],
).padded(),
).padded(),

View File

@@ -0,0 +1,602 @@
//TODO: REMOVE THIS WHEN NATIVE MATERIAL FLUTTER SEARCH IS RELEASED
// Copyright 2014 The Flutter Authors. All rights reserved.
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
/// Shows a full screen search page and returns the search result selected by
/// the user when the page is closed.
///
/// The search page consists of an app bar with a search field and a body which
/// can either show suggested search queries or the search results.
///
/// The appearance of the search page is determined by the provided
/// `delegate`. The initial query string is given by `query`, which defaults
/// to the empty string. When `query` is set to null, `delegate.query` will
/// be used as the initial query.
///
/// This method returns the selected search result, which can be set in the
/// [SearchDelegate.close] call. If the search page is closed with the system
/// back button, it returns null.
///
/// A given [SearchDelegate] can only be associated with one active [showMaterial3Search]
/// call. Call [SearchDelegate.close] before re-using the same delegate instance
/// for another [showMaterial3Search] call.
///
/// The `useRootNavigator` argument is used to determine whether to push the
/// search page to the [Navigator] furthest from or nearest to the given
/// `context`. By default, `useRootNavigator` is `false` and the search page
/// route created by this method is pushed to the nearest navigator to the
/// given `context`. It can not be `null`.
///
/// The transition to the search page triggered by this method looks best if the
/// screen triggering the transition contains an [AppBar] at the top and the
/// transition is called from an [IconButton] that's part of [AppBar.actions].
/// The animation provided by [SearchDelegate.transitionAnimation] can be used
/// to trigger additional animations in the underlying page while the search
/// page fades in or out. This is commonly used to animate an [AnimatedIcon] in
/// the [AppBar.leading] position e.g. from the hamburger menu to the back arrow
/// used to exit the search page.
///
/// ## Handling emojis and other complex characters
/// {@macro flutter.widgets.EditableText.onChanged}
///
/// See also:
///
/// * [SearchDelegate] to define the content of the search page.
Future<T?> showMaterial3Search<T>({
required BuildContext context,
required SearchDelegate<T> delegate,
String? query = '',
bool useRootNavigator = false,
}) {
delegate.query = query ?? delegate.query;
delegate._currentBody = _SearchBody.suggestions;
return Navigator.of(context, rootNavigator: useRootNavigator)
.push(_SearchPageRoute<T>(
delegate: delegate,
));
}
/// Delegate for [showMaterial3Search] to define the content of the search page.
///
/// The search page always shows an [AppBar] at the top where users can
/// enter their search queries. The buttons shown before and after the search
/// query text field can be customized via [SearchDelegate.buildLeading]
/// and [SearchDelegate.buildActions]. Additionally, a widget can be placed
/// across the bottom of the [AppBar] via [SearchDelegate.buildBottom].
///
/// The body below the [AppBar] can either show suggested queries (returned by
/// [SearchDelegate.buildSuggestions]) or - once the user submits a search - the
/// results of the search as returned by [SearchDelegate.buildResults].
///
/// [SearchDelegate.query] always contains the current query entered by the user
/// and should be used to build the suggestions and results.
///
/// The results can be brought on screen by calling [SearchDelegate.showResults]
/// and you can go back to showing the suggestions by calling
/// [SearchDelegate.showSuggestions].
///
/// Once the user has selected a search result, [SearchDelegate.close] should be
/// called to remove the search page from the top of the navigation stack and
/// to notify the caller of [showMaterial3Search] about the selected search result.
///
/// A given [SearchDelegate] can only be associated with one active [showMaterial3Search]
/// call. Call [SearchDelegate.close] before re-using the same delegate instance
/// for another [showMaterial3Search] call.
///
/// ## Handling emojis and other complex characters
/// {@macro flutter.widgets.EditableText.onChanged}
abstract class SearchDelegate<T> {
/// Constructor to be called by subclasses which may specify
/// [searchFieldLabel], either [searchFieldStyle] or [searchFieldDecorationTheme],
/// [keyboardType] and/or [textInputAction]. Only one of [searchFieldLabel]
/// and [searchFieldDecorationTheme] may be non-null.
///
/// {@tool snippet}
/// ```dart
/// class CustomSearchHintDelegate extends SearchDelegate<String> {
/// CustomSearchHintDelegate({
/// required String hintText,
/// }) : super(
/// searchFieldLabel: hintText,
/// keyboardType: TextInputType.text,
/// textInputAction: TextInputAction.search,
/// );
///
/// @override
/// Widget buildLeading(BuildContext context) => const Text('leading');
///
/// @override
/// PreferredSizeWidget buildBottom(BuildContext context) {
/// return const PreferredSize(
/// preferredSize: Size.fromHeight(56.0),
/// child: Text('bottom'));
/// }
///
/// @override
/// Widget buildSuggestions(BuildContext context) => const Text('suggestions');
///
/// @override
/// Widget buildResults(BuildContext context) => const Text('results');
///
/// @override
/// List<Widget> buildActions(BuildContext context) => <Widget>[];
/// }
/// ```
/// {@end-tool}
SearchDelegate({
this.searchFieldLabel,
this.searchFieldStyle,
this.searchFieldDecorationTheme,
this.keyboardType,
this.textInputAction = TextInputAction.search,
}) : assert(searchFieldStyle == null || searchFieldDecorationTheme == null);
/// Suggestions shown in the body of the search page while the user types a
/// query into the search field.
///
/// The delegate method is called whenever the content of [query] changes.
/// The suggestions should be based on the current [query] string. If the query
/// string is empty, it is good practice to show suggested queries based on
/// past queries or the current context.
///
/// Usually, this method will return a [ListView] with one [ListTile] per
/// suggestion. When [ListTile.onTap] is called, [query] should be updated
/// with the corresponding suggestion and the results page should be shown
/// by calling [showResults].
Widget buildSuggestions(BuildContext context);
/// The results shown after the user submits a search from the search page.
///
/// The current value of [query] can be used to determine what the user
/// searched for.
///
/// This method might be applied more than once to the same query.
/// If your [buildResults] method is computationally expensive, you may want
/// to cache the search results for one or more queries.
///
/// Typically, this method returns a [ListView] with the search results.
/// When the user taps on a particular search result, [close] should be called
/// with the selected result as argument. This will close the search page and
/// communicate the result back to the initial caller of [showMaterial3Search].
Widget buildResults(BuildContext context);
/// A widget to display before the current query in the [AppBar].
///
/// Typically an [IconButton] configured with a [BackButtonIcon] that exits
/// the search with [close]. One can also use an [AnimatedIcon] driven by
/// [transitionAnimation], which animates from e.g. a hamburger menu to the
/// back button as the search overlay fades in.
///
/// Returns null if no widget should be shown.
///
/// See also:
///
/// * [AppBar.leading], the intended use for the return value of this method.
Widget? buildLeading(BuildContext context);
/// Widgets to display after the search query in the [AppBar].
///
/// If the [query] is not empty, this should typically contain a button to
/// clear the query and show the suggestions again (via [showSuggestions]) if
/// the results are currently shown.
///
/// Returns null if no widget should be shown.
///
/// See also:
///
/// * [AppBar.actions], the intended use for the return value of this method.
List<Widget>? buildActions(BuildContext context);
/// Widget to display across the bottom of the [AppBar].
///
/// Returns null by default, i.e. a bottom widget is not included.
///
/// See also:
///
/// * [AppBar.bottom], the intended use for the return value of this method.
///
PreferredSizeWidget? buildBottom(BuildContext context) => null;
/// The theme used to configure the search page.
///
/// The returned [ThemeData] will be used to wrap the entire search page,
/// so it can be used to configure any of its components with the appropriate
/// theme properties.
///
/// Unless overridden, the default theme will configure the AppBar containing
/// the search input text field with a white background and black text on light
/// themes. For dark themes the default is a dark grey background with light
/// color text.
///
/// See also:
///
/// * [AppBarTheme], which configures the AppBar's appearance.
/// * [InputDecorationTheme], which configures the appearance of the search
/// text field.
ThemeData appBarTheme(BuildContext context) {
final ThemeData theme = Theme.of(context);
final ColorScheme colorScheme = theme.colorScheme;
return theme.copyWith(
appBarTheme: AppBarTheme(
systemOverlayStyle: colorScheme.brightness == Brightness.light
? SystemUiOverlayStyle.light
: SystemUiOverlayStyle.dark,
backgroundColor: colorScheme.brightness == Brightness.dark
? Colors.grey[900]
: Colors.white,
iconTheme: theme.primaryIconTheme.copyWith(color: Colors.grey),
),
inputDecorationTheme: searchFieldDecorationTheme ??
InputDecorationTheme(
hintStyle: searchFieldStyle ?? theme.inputDecorationTheme.hintStyle,
border: InputBorder.none,
),
);
}
/// The current query string shown in the [AppBar].
///
/// The user manipulates this string via the keyboard.
///
/// If the user taps on a suggestion provided by [buildSuggestions] this
/// string should be updated to that suggestion via the setter.
String get query => _queryTextController.text;
/// Changes the current query string.
///
/// Setting the query string programmatically moves the cursor to the end of the text field.
set query(String value) {
assert(query != null);
_queryTextController.text = value;
if (_queryTextController.text.isNotEmpty) {
_queryTextController.selection = TextSelection.fromPosition(
TextPosition(offset: _queryTextController.text.length));
}
}
/// Transition from the suggestions returned by [buildSuggestions] to the
/// [query] results returned by [buildResults].
///
/// If the user taps on a suggestion provided by [buildSuggestions] the
/// screen should typically transition to the page showing the search
/// results for the suggested query. This transition can be triggered
/// by calling this method.
///
/// See also:
///
/// * [showSuggestions] to show the search suggestions again.
void showResults(BuildContext context) {
_focusNode?.unfocus();
_currentBody = _SearchBody.results;
}
/// Transition from showing the results returned by [buildResults] to showing
/// the suggestions returned by [buildSuggestions].
///
/// Calling this method will also put the input focus back into the search
/// field of the [AppBar].
///
/// If the results are currently shown this method can be used to go back
/// to showing the search suggestions.
///
/// See also:
///
/// * [showResults] to show the search results.
void showSuggestions(BuildContext context) {
assert(_focusNode != null,
'_focusNode must be set by route before showSuggestions is called.');
_focusNode!.requestFocus();
_currentBody = _SearchBody.suggestions;
}
/// Closes the search page and returns to the underlying route.
///
/// The value provided for `result` is used as the return value of the call
/// to [showMaterial3Search] that launched the search initially.
void close(BuildContext context, T result) {
_currentBody = null;
_focusNode?.unfocus();
Navigator.of(context)
..popUntil((Route<dynamic> route) => route == _route)
..pop(result);
}
/// The hint text that is shown in the search field when it is empty.
///
/// If this value is set to null, the value of
/// `MaterialLocalizations.of(context).searchFieldLabel` will be used instead.
final String? searchFieldLabel;
/// The style of the [searchFieldLabel].
///
/// If this value is set to null, the value of the ambient [Theme]'s
/// [InputDecorationTheme.hintStyle] will be used instead.
///
/// Only one of [searchFieldStyle] or [searchFieldDecorationTheme] can
/// be non-null.
final TextStyle? searchFieldStyle;
/// The [InputDecorationTheme] used to configure the search field's visuals.
///
/// Only one of [searchFieldStyle] or [searchFieldDecorationTheme] can
/// be non-null.
final InputDecorationTheme? searchFieldDecorationTheme;
/// The type of action button to use for the keyboard.
///
/// Defaults to the default value specified in [TextField].
final TextInputType? keyboardType;
/// The text input action configuring the soft keyboard to a particular action
/// button.
///
/// Defaults to [TextInputAction.search].
final TextInputAction textInputAction;
/// [Animation] triggered when the search pages fades in or out.
///
/// This animation is commonly used to animate [AnimatedIcon]s of
/// [IconButton]s returned by [buildLeading] or [buildActions]. It can also be
/// used to animate [IconButton]s contained within the route below the search
/// page.
Animation<double> get transitionAnimation => _proxyAnimation;
// The focus node to use for manipulating focus on the search page. This is
// managed, owned, and set by the _SearchPageRoute using this delegate.
FocusNode? _focusNode;
final TextEditingController _queryTextController = TextEditingController();
final ProxyAnimation _proxyAnimation =
ProxyAnimation(kAlwaysDismissedAnimation);
final ValueNotifier<_SearchBody?> _currentBodyNotifier =
ValueNotifier<_SearchBody?>(null);
_SearchBody? get _currentBody => _currentBodyNotifier.value;
set _currentBody(_SearchBody? value) {
_currentBodyNotifier.value = value;
}
_SearchPageRoute<T>? _route;
}
/// Describes the body that is currently shown under the [AppBar] in the
/// search page.
enum _SearchBody {
/// Suggested queries are shown in the body.
///
/// The suggested queries are generated by [SearchDelegate.buildSuggestions].
suggestions,
/// Search results are currently shown in the body.
///
/// The search results are generated by [SearchDelegate.buildResults].
results,
}
class _SearchPageRoute<T> extends PageRoute<T> {
_SearchPageRoute({
required this.delegate,
}) {
assert(
delegate._route == null,
'The ${delegate.runtimeType} instance is currently used by another active '
'search. Please close that search by calling close() on the SearchDelegate '
'before opening another search with the same delegate instance.',
);
delegate._route = this;
}
final SearchDelegate<T> delegate;
@override
Color? get barrierColor => null;
@override
String? get barrierLabel => null;
@override
Duration get transitionDuration => const Duration(milliseconds: 300);
@override
bool get maintainState => false;
@override
Widget buildTransitions(
BuildContext context,
Animation<double> animation,
Animation<double> secondaryAnimation,
Widget child,
) {
return FadeTransition(
opacity: animation,
child: child,
);
}
@override
Animation<double> createAnimation() {
final Animation<double> animation = super.createAnimation();
delegate._proxyAnimation.parent = animation;
return animation;
}
@override
Widget buildPage(
BuildContext context,
Animation<double> animation,
Animation<double> secondaryAnimation,
) {
return _SearchPage<T>(
delegate: delegate,
animation: animation,
);
}
@override
void didComplete(T? result) {
super.didComplete(result);
assert(delegate._route == this);
delegate._route = null;
delegate._currentBody = null;
}
}
class _SearchPage<T> extends StatefulWidget {
const _SearchPage({
required this.delegate,
required this.animation,
});
final SearchDelegate<T> delegate;
final Animation<double> animation;
@override
State<StatefulWidget> createState() => _SearchPageState<T>();
}
class _SearchPageState<T> extends State<_SearchPage<T>> {
// This node is owned, but not hosted by, the search page. Hosting is done by
// the text field.
FocusNode focusNode = FocusNode();
@override
void initState() {
super.initState();
widget.delegate._queryTextController.addListener(_onQueryChanged);
widget.animation.addStatusListener(_onAnimationStatusChanged);
widget.delegate._currentBodyNotifier.addListener(_onSearchBodyChanged);
focusNode.addListener(_onFocusChanged);
widget.delegate._focusNode = focusNode;
}
@override
void dispose() {
super.dispose();
widget.delegate._queryTextController.removeListener(_onQueryChanged);
widget.animation.removeStatusListener(_onAnimationStatusChanged);
widget.delegate._currentBodyNotifier.removeListener(_onSearchBodyChanged);
widget.delegate._focusNode = null;
focusNode.dispose();
}
void _onAnimationStatusChanged(AnimationStatus status) {
if (status != AnimationStatus.completed) {
return;
}
widget.animation.removeStatusListener(_onAnimationStatusChanged);
if (widget.delegate._currentBody == _SearchBody.suggestions) {
focusNode.requestFocus();
}
}
@override
void didUpdateWidget(_SearchPage<T> oldWidget) {
super.didUpdateWidget(oldWidget);
if (widget.delegate != oldWidget.delegate) {
oldWidget.delegate._queryTextController.removeListener(_onQueryChanged);
widget.delegate._queryTextController.addListener(_onQueryChanged);
oldWidget.delegate._currentBodyNotifier
.removeListener(_onSearchBodyChanged);
widget.delegate._currentBodyNotifier.addListener(_onSearchBodyChanged);
oldWidget.delegate._focusNode = null;
widget.delegate._focusNode = focusNode;
}
}
void _onFocusChanged() {
if (focusNode.hasFocus &&
widget.delegate._currentBody != _SearchBody.suggestions) {
widget.delegate.showSuggestions(context);
}
}
void _onQueryChanged() {
setState(() {
// rebuild ourselves because query changed.
});
}
void _onSearchBodyChanged() {
setState(() {
// rebuild ourselves because search body changed.
});
}
@override
Widget build(BuildContext context) {
assert(debugCheckHasMaterialLocalizations(context));
final ThemeData theme = widget.delegate.appBarTheme(context);
final String searchFieldLabel = widget.delegate.searchFieldLabel ??
MaterialLocalizations.of(context).searchFieldLabel;
Widget? body;
switch (widget.delegate._currentBody) {
case _SearchBody.suggestions:
body = KeyedSubtree(
key: const ValueKey<_SearchBody>(_SearchBody.suggestions),
child: widget.delegate.buildSuggestions(context),
);
break;
case _SearchBody.results:
body = KeyedSubtree(
key: const ValueKey<_SearchBody>(_SearchBody.results),
child: widget.delegate.buildResults(context),
);
break;
case null:
break;
}
late final String routeName;
switch (theme.platform) {
case TargetPlatform.iOS:
case TargetPlatform.macOS:
routeName = '';
break;
case TargetPlatform.android:
case TargetPlatform.fuchsia:
case TargetPlatform.linux:
case TargetPlatform.windows:
routeName = searchFieldLabel;
}
return Semantics(
explicitChildNodes: true,
scopesRoute: true,
namesRoute: true,
label: routeName,
child: Theme(
data: theme,
child: Scaffold(
appBar: AppBar(
toolbarHeight: 72,
leading: widget.delegate.buildLeading(context),
title: TextField(
controller: widget.delegate._queryTextController,
focusNode: focusNode,
style: widget.delegate.searchFieldStyle ??
theme.textTheme.titleLarge,
textInputAction: widget.delegate.textInputAction,
keyboardType: widget.delegate.keyboardType,
onSubmitted: (String _) {
widget.delegate.showResults(context);
},
decoration: InputDecoration(hintText: searchFieldLabel),
),
actions: widget.delegate.buildActions(context),
bottom: widget.delegate.buildBottom(context),
),
body: AnimatedSwitcher(
duration: const Duration(milliseconds: 300),
child: body,
),
),
),
);
}
}

View File

@@ -0,0 +1,79 @@
import 'dart:math';
import 'package:flutter/material.dart';
class SearchBar extends StatelessWidget {
const SearchBar({
Key? key,
this.height = 56,
required this.leadingIcon,
this.trailingIcon,
required this.supportingText,
required this.onTap,
}) : super(key: key);
final double height;
double get effectiveHeight {
return max(height, 48);
}
final VoidCallback onTap;
final Widget leadingIcon;
final Widget? trailingIcon;
final String supportingText;
@override
Widget build(BuildContext context) {
final ColorScheme colorScheme = Theme.of(context).colorScheme;
final TextTheme textTheme = Theme.of(context).textTheme;
return Container(
constraints: const BoxConstraints(minWidth: 360, maxWidth: 720),
width: double.infinity,
height: effectiveHeight,
child: Material(
elevation: 1,
color: colorScheme.surface,
shadowColor: colorScheme.shadow,
surfaceTintColor: colorScheme.surfaceTint,
borderRadius: BorderRadius.circular(effectiveHeight / 2),
child: InkWell(
onTap: onTap,
borderRadius: BorderRadius.circular(effectiveHeight / 2),
highlightColor: Colors.transparent,
splashFactory: InkRipple.splashFactory,
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 8),
child: Row(children: [
leadingIcon,
Expanded(
child: Padding(
padding: const EdgeInsets.only(right: 8),
child: TextField(
onTap: onTap,
readOnly: true,
enabled: false,
cursorColor: colorScheme.primary,
style: textTheme.bodyLarge,
textAlignVertical: TextAlignVertical.center,
decoration: InputDecoration(
isCollapsed: true,
border: InputBorder.none,
contentPadding: const EdgeInsets.symmetric(horizontal: 8),
hintText: supportingText,
hintStyle: textTheme.bodyLarge?.apply(
color: colorScheme.onSurfaceVariant,
),
),
),
),
),
if (trailingIcon != null) trailingIcon!,
]),
),
),
),
);
}
}

View File

@@ -2,18 +2,25 @@ import 'package:flutter/material.dart';
import 'package:flutter_svg/flutter_svg.dart';
class PaperlessLogo extends StatelessWidget {
static const _paperlessGreen = Color(0xFF18541F);
final double? height;
final double? width;
final String _path;
final Color _color;
const PaperlessLogo.white({super.key, this.height, this.width})
: _path = "assets/logos/paperless_logo_white.svg";
const PaperlessLogo.white({
super.key,
this.height,
this.width,
}) : _color = Colors.white;
const PaperlessLogo.green({super.key, this.height, this.width})
: _path = "assets/logos/paperless_logo_green.svg";
: _color = _paperlessGreen;
const PaperlessLogo.black({super.key, this.height, this.width})
: _path = "assets/logos/paperless_logo_black.svg";
: _color = Colors.black;
const PaperlessLogo.colored(Color color, {super.key, this.height, this.width})
: _color = color;
@override
Widget build(BuildContext context) {
@@ -24,7 +31,8 @@ class PaperlessLogo extends StatelessWidget {
),
padding: const EdgeInsets.only(right: 8),
child: SvgPicture.asset(
_path,
"assets/logos/paperless_logo_white.svg",
color: _color,
),
);
}

View File

@@ -0,0 +1,21 @@
import 'package:flutter/material.dart';
import 'package:shimmer/shimmer.dart';
class ShimmerPlaceholder extends StatelessWidget {
final Widget child;
const ShimmerPlaceholder({super.key, required this.child});
@override
Widget build(BuildContext context) {
return Shimmer.fromColors(
baseColor: Theme.of(context).brightness == Brightness.light
? Colors.grey[300]!
: Colors.grey[900]!,
highlightColor: Theme.of(context).brightness == Brightness.light
? Colors.grey[100]!
: Colors.grey[600]!,
child: child,
);
}
}