Initial commit

This commit is contained in:
Anton Stubenbord
2022-10-30 14:15:37 +01:00
commit cb797df7d2
272 changed files with 16278 additions and 0 deletions

View File

@@ -0,0 +1,22 @@
import 'package:flutter_paperless_mobile/core/bloc/label_cubit.dart';
import 'package:flutter_paperless_mobile/features/labels/correspondent/model/correspondent.model.dart';
import 'package:injectable/injectable.dart';
@singleton
class CorrespondentCubit extends LabelCubit<Correspondent> {
CorrespondentCubit(super.metaDataService);
@override
Future<void> initialize() async {
return labelRepository.getCorrespondents().then(loadFrom);
}
@override
Future<Correspondent> save(Correspondent item) => labelRepository.saveCorrespondent(item);
@override
Future<Correspondent> update(Correspondent item) => labelRepository.updateCorrespondent(item);
@override
Future<int> delete(Correspondent item) => labelRepository.deleteCorrespondent(item);
}

View File

@@ -0,0 +1,65 @@
import 'package:flutter_paperless_mobile/core/type/json.dart';
import 'package:flutter_paperless_mobile/extensions/dart_extensions.dart';
import 'package:flutter_paperless_mobile/features/labels/document_type/model/matching_algorithm.dart';
import 'package:flutter_paperless_mobile/features/labels/model/label.model.dart';
class Correspondent extends Label {
static const lastCorrespondenceKey = 'last_correspondence';
late DateTime? lastCorrespondence;
Correspondent({
required super.id,
required super.name,
super.slug,
super.match,
super.matchingAlgorithm,
super.isInsensitive,
super.documentCount,
this.lastCorrespondence,
});
Correspondent.fromJson(JSON json)
: lastCorrespondence =
DateTime.tryParse(json[lastCorrespondenceKey] ?? ''),
super.fromJson(json);
@override
String toString() {
return name;
}
@override
void addSpecificFieldsToJson(JSON json) {
json.tryPutIfAbsent(
lastCorrespondenceKey,
() => lastCorrespondence?.toIso8601String(),
);
}
@override
Correspondent copyWith({
int? id,
String? name,
String? slug,
String? match,
MatchingAlgorithm? matchingAlgorithm,
bool? isInsensitive,
int? documentCount,
DateTime? lastCorrespondence,
}) {
return Correspondent(
id: id ?? this.id,
name: name ?? this.name,
documentCount: documentCount ?? documentCount,
isInsensitive: isInsensitive ?? isInsensitive,
lastCorrespondence: lastCorrespondence ?? this.lastCorrespondence,
match: match ?? this.match,
matchingAlgorithm: matchingAlgorithm ?? this.matchingAlgorithm,
slug: slug ?? this.slug,
);
}
@override
String get queryEndpoint => 'correspondents';
}

View File

@@ -0,0 +1,21 @@
import 'package:flutter/widgets.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:flutter_paperless_mobile/features/labels/correspondent/bloc/correspondents_cubit.dart';
import 'package:flutter_paperless_mobile/features/labels/correspondent/model/correspondent.model.dart';
import 'package:flutter_paperless_mobile/features/labels/view/pages/add_label_page.dart';
import 'package:flutter_paperless_mobile/generated/l10n.dart';
class AddCorrespondentPage extends StatelessWidget {
final String? initalValue;
const AddCorrespondentPage({Key? key, this.initalValue}) : super(key: key);
@override
Widget build(BuildContext context) {
return AddLabelPage<Correspondent>(
addLabelStr: S.of(context).addCorrespondentPageTitle,
fromJson: Correspondent.fromJson,
cubit: BlocProvider.of<CorrespondentCubit>(context),
initialName: initalValue,
);
}
}

View File

@@ -0,0 +1,41 @@
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:flutter_paperless_mobile/core/logic/error_code_localization_mapper.dart';
import 'package:flutter_paperless_mobile/core/model/error_message.dart';
import 'package:flutter_paperless_mobile/features/documents/bloc/documents_cubit.dart';
import 'package:flutter_paperless_mobile/features/documents/model/query_parameters/correspondent_query.dart';
import 'package:flutter_paperless_mobile/features/labels/correspondent/bloc/correspondents_cubit.dart';
import 'package:flutter_paperless_mobile/features/labels/correspondent/model/correspondent.model.dart';
import 'package:flutter_paperless_mobile/features/labels/view/pages/edit_label_page.dart';
import 'package:flutter_paperless_mobile/util.dart';
class EditCorrespondentPage extends StatelessWidget {
final Correspondent correspondent;
const EditCorrespondentPage({super.key, required this.correspondent});
@override
Widget build(BuildContext context) {
return EditLabelPage<Correspondent>(
label: correspondent,
onSubmit: BlocProvider.of<CorrespondentCubit>(context).replace,
onDelete: (correspondent) => _onDelete(correspondent, context),
fromJson: Correspondent.fromJson,
);
}
Future<void> _onDelete(Correspondent correspondent, BuildContext context) async {
try {
await BlocProvider.of<CorrespondentCubit>(context).remove(correspondent);
final cubit = BlocProvider.of<DocumentsCubit>(context);
if (cubit.state.filter.correspondent.id == correspondent.id) {
cubit.updateFilter(
filter: cubit.state.filter.copyWith(correspondent: const CorrespondentQuery.unset()),
);
}
} on ErrorMessage catch (e) {
showSnackBar(context, translateError(context, e.code));
} finally {
Navigator.pop(context);
}
}
}

View File

@@ -0,0 +1,59 @@
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:flutter_paperless_mobile/di_initializer.dart';
import 'package:flutter_paperless_mobile/features/documents/bloc/documents_cubit.dart';
import 'package:flutter_paperless_mobile/features/documents/model/query_parameters/correspondent_query.dart';
import 'package:flutter_paperless_mobile/features/labels/correspondent/bloc/correspondents_cubit.dart';
import 'package:flutter_paperless_mobile/features/labels/correspondent/model/correspondent.model.dart';
class CorrespondentWidget extends StatelessWidget {
final int? correspondentId;
final void Function()? afterSelected;
final Color? textColor;
final bool isClickable;
const CorrespondentWidget({
Key? key,
required this.correspondentId,
this.afterSelected,
this.textColor,
this.isClickable = true,
}) : super(key: key);
@override
Widget build(BuildContext context) {
return AbsorbPointer(
absorbing: !isClickable,
child: BlocBuilder<CorrespondentCubit, Map<int, Correspondent>>(
builder: (context, state) {
return GestureDetector(
onTap: () => _addCorrespondentToFilter(context),
child: Text(
(state[correspondentId]?.name) ?? "-",
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: Theme.of(context).textTheme.bodyText2?.copyWith(
color: textColor ?? Theme.of(context).colorScheme.primary,
),
),
);
},
),
);
}
void _addCorrespondentToFilter(BuildContext context) {
final cubit = getIt<DocumentsCubit>();
if (cubit.state.filter.correspondent.id == correspondentId) {
cubit.updateFilter(
filter: cubit.state.filter.copyWith(correspondent: const CorrespondentQuery.unset()));
} else {
cubit.updateFilter(
filter: cubit.state.filter.copyWith(
correspondent: CorrespondentQuery.fromId(correspondentId),
),
);
}
afterSelected?.call();
}
}

View File

@@ -0,0 +1,22 @@
import 'package:flutter_paperless_mobile/core/bloc/label_cubit.dart';
import 'package:flutter_paperless_mobile/features/labels/document_type/model/document_type.model.dart';
import 'package:injectable/injectable.dart';
@singleton
class DocumentTypeCubit extends LabelCubit<DocumentType> {
DocumentTypeCubit(super.metaDataService);
@override
Future<void> initialize() async {
return labelRepository.getDocumentTypes().then(loadFrom);
}
@override
Future<DocumentType> save(DocumentType item) => labelRepository.saveDocumentType(item);
@override
Future<DocumentType> update(DocumentType item) => labelRepository.updateDocumentType(item);
@override
Future<int> delete(DocumentType item) => labelRepository.deleteDocumentType(item);
}

View File

@@ -0,0 +1,44 @@
import 'package:flutter_paperless_mobile/core/type/json.dart';
import 'package:flutter_paperless_mobile/features/labels/document_type/model/matching_algorithm.dart';
import 'package:flutter_paperless_mobile/features/labels/model/label.model.dart';
class DocumentType extends Label {
DocumentType({
required super.id,
required super.name,
super.slug,
super.match,
super.matchingAlgorithm,
super.isInsensitive,
super.documentCount,
});
DocumentType.fromJson(JSON json) : super.fromJson(json);
@override
void addSpecificFieldsToJson(JSON json) {}
@override
String get queryEndpoint => 'document_types';
@override
DocumentType copyWith({
int? id,
String? name,
String? match,
MatchingAlgorithm? matchingAlgorithm,
bool? isInsensitive,
int? documentCount,
String? slug,
}) {
return DocumentType(
id: id ?? this.id,
name: name ?? this.name,
match: match ?? this.match,
matchingAlgorithm: matchingAlgorithm ?? this.matchingAlgorithm,
isInsensitive: isInsensitive ?? this.isInsensitive,
documentCount: documentCount ?? this.documentCount,
slug: slug ?? this.slug,
);
}
}

View File

@@ -0,0 +1,22 @@
enum MatchingAlgorithm {
anyWord(1, "Any: Match one of the following words"),
allWords(2, "All: Match all of the following words"),
exactMatch(3, "Exact: Match the following string"),
regex(4, "Regex: Match the regular expression"),
similarWord(5, "Similar: Match a similar word"),
auto(6, "Auto: Learn automatic assignment");
final int value;
final String name;
const MatchingAlgorithm(this.value, this.name);
static MatchingAlgorithm fromInt(int? value) {
return MatchingAlgorithm.values
.where((element) => element.value == value)
.firstWhere(
(element) => true,
orElse: () => MatchingAlgorithm.anyWord,
);
}
}

View File

@@ -0,0 +1,21 @@
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:flutter_paperless_mobile/features/labels/document_type/bloc/document_type_cubit.dart';
import 'package:flutter_paperless_mobile/features/labels/document_type/model/document_type.model.dart';
import 'package:flutter_paperless_mobile/features/labels/view/pages/add_label_page.dart';
import 'package:flutter_paperless_mobile/generated/l10n.dart';
class AddDocumentTypePage extends StatelessWidget {
final String? initialName;
const AddDocumentTypePage({Key? key, this.initialName}) : super(key: key);
@override
Widget build(BuildContext context) {
return AddLabelPage<DocumentType>(
addLabelStr: S.of(context).addDocumentTypePageTitle,
fromJson: DocumentType.fromJson,
cubit: BlocProvider.of<DocumentTypeCubit>(context),
initialName: initialName,
);
}
}

View File

@@ -0,0 +1,41 @@
import 'package:flutter/widgets.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:flutter_paperless_mobile/core/logic/error_code_localization_mapper.dart';
import 'package:flutter_paperless_mobile/core/model/error_message.dart';
import 'package:flutter_paperless_mobile/features/documents/bloc/documents_cubit.dart';
import 'package:flutter_paperless_mobile/features/documents/model/query_parameters/document_type_query.dart';
import 'package:flutter_paperless_mobile/features/labels/document_type/bloc/document_type_cubit.dart';
import 'package:flutter_paperless_mobile/features/labels/document_type/model/document_type.model.dart';
import 'package:flutter_paperless_mobile/features/labels/view/pages/edit_label_page.dart';
import 'package:flutter_paperless_mobile/util.dart';
class EditDocumentTypePage extends StatelessWidget {
final DocumentType documentType;
const EditDocumentTypePage({super.key, required this.documentType});
@override
Widget build(BuildContext context) {
return EditLabelPage<DocumentType>(
label: documentType,
onSubmit: BlocProvider.of<DocumentTypeCubit>(context).replace,
onDelete: (docType) => _onDelete(docType, context),
fromJson: DocumentType.fromJson,
);
}
Future<void> _onDelete(DocumentType docType, BuildContext context) async {
try {
await BlocProvider.of<DocumentTypeCubit>(context).remove(docType);
final cubit = BlocProvider.of<DocumentsCubit>(context);
if (cubit.state.filter.documentType.id == docType.id) {
cubit.updateFilter(
filter: cubit.state.filter.copyWith(documentType: const DocumentTypeQuery.unset()),
);
}
} on ErrorMessage catch (e) {
showSnackBar(context, translateError(context, e.code));
} finally {
Navigator.pop(context);
}
}
}

View File

@@ -0,0 +1,52 @@
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:flutter_paperless_mobile/di_initializer.dart';
import 'package:flutter_paperless_mobile/features/documents/model/query_parameters/document_type_query.dart';
import 'package:flutter_paperless_mobile/features/labels/document_type/bloc/document_type_cubit.dart';
import 'package:flutter_paperless_mobile/features/documents/bloc/documents_cubit.dart';
import 'package:flutter_paperless_mobile/features/labels/document_type/model/document_type.model.dart';
class DocumentTypeWidget extends StatelessWidget {
final int? documentTypeId;
final void Function()? afterSelected;
const DocumentTypeWidget({
Key? key,
required this.documentTypeId,
this.afterSelected,
}) : super(key: key);
@override
Widget build(BuildContext context) {
return GestureDetector(
onTap: _addDocumentTypeToFilter,
child: BlocBuilder<DocumentTypeCubit, Map<int, DocumentType>>(
builder: (context, state) {
return Text(
state[documentTypeId]?.toString() ?? "-",
style: Theme.of(context)
.textTheme
.bodyText2!
.copyWith(color: Theme.of(context).colorScheme.primary),
);
},
),
);
}
void _addDocumentTypeToFilter() {
final cubit = getIt<DocumentsCubit>();
if (cubit.state.filter.documentType.id == documentTypeId) {
cubit.updateFilter(
filter: cubit.state.filter.copyWith(documentType: const DocumentTypeQuery.unset()));
} else {
cubit.updateFilter(
filter: cubit.state.filter.copyWith(
documentType: DocumentTypeQuery.fromId(documentTypeId),
),
);
}
if (afterSelected != null) {
afterSelected?.call();
}
}
}

View File

@@ -0,0 +1,82 @@
import 'package:equatable/equatable.dart';
import 'package:flutter_paperless_mobile/core/type/json.dart';
import 'package:flutter_paperless_mobile/extensions/dart_extensions.dart';
import 'package:flutter_paperless_mobile/features/labels/document_type/model/matching_algorithm.dart';
abstract class Label with EquatableMixin implements Comparable {
static const idKey = "id";
static const nameKey = "name";
static const slugKey = "slug";
static const matchKey = "match";
static const matchingAlgorithmKey = "matching_algorithm";
static const isInsensitiveKey = "is_insensitive";
static const documentCountKey = "document_count";
String get queryEndpoint;
final int? id;
final String name;
final String? slug;
final String? match;
final MatchingAlgorithm? matchingAlgorithm;
final bool? isInsensitive;
final int? documentCount;
const Label({
required this.id,
required this.name,
this.match,
this.matchingAlgorithm,
this.isInsensitive,
this.documentCount,
this.slug,
});
Label.fromJson(JSON json)
: id = json[idKey],
name = json[nameKey],
slug = json[slugKey],
match = json[matchKey],
matchingAlgorithm =
MatchingAlgorithm.fromInt(json[matchingAlgorithmKey]),
isInsensitive = json[isInsensitiveKey],
documentCount = json[documentCountKey];
JSON toJson() {
JSON json = {};
json.tryPutIfAbsent(idKey, () => id);
json.tryPutIfAbsent(nameKey, () => name);
json.tryPutIfAbsent(slugKey, () => slug);
json.tryPutIfAbsent(matchKey, () => match);
json.tryPutIfAbsent(matchingAlgorithmKey, () => matchingAlgorithm?.value);
json.tryPutIfAbsent(isInsensitiveKey, () => isInsensitive);
json.tryPutIfAbsent(documentCountKey, () => documentCount);
addSpecificFieldsToJson(json);
return json;
}
void addSpecificFieldsToJson(JSON json);
Label copyWith({
int? id,
String? name,
String? match,
MatchingAlgorithm? matchingAlgorithm,
bool? isInsensitive,
int? documentCount,
String? slug,
});
@override
String toString() {
return name;
}
@override
int compareTo(dynamic other) {
return toString().toLowerCase().compareTo(other.toString().toLowerCase());
}
@override
List<Object?> get props => [id];
}

View File

@@ -0,0 +1,246 @@
import 'dart:convert';
import 'package:flutter_paperless_mobile/core/model/error_message.dart';
import 'package:flutter_paperless_mobile/core/util.dart';
import 'package:flutter_paperless_mobile/di_initializer.dart';
import 'package:flutter_paperless_mobile/features/labels/correspondent/model/correspondent.model.dart';
import 'package:flutter_paperless_mobile/features/labels/document_type/model/document_type.model.dart';
import 'package:flutter_paperless_mobile/features/labels/repository/label_repository.dart';
import 'package:flutter_paperless_mobile/features/labels/storage_path/model/storage_path.model.dart';
import 'package:flutter_paperless_mobile/features/labels/tags/model/tag.model.dart';
import 'package:http/http.dart';
import 'package:injectable/injectable.dart';
@Singleton(as: LabelRepository)
class LabelRepositoryImpl implements LabelRepository {
final BaseClient httpClient;
LabelRepositoryImpl(@Named("timeoutClient") this.httpClient);
@override
Future<Correspondent?> getCorrespondent(int id) async {
return getSingleResult(
"/api/correspondents/$id/",
Correspondent.fromJson,
ErrorCode.correspondentLoadFailed,
);
}
@override
Future<Tag?> getTag(int id) async {
return getSingleResult("/api/tags/$id/", Tag.fromJson, ErrorCode.tagLoadFailed);
}
@override
Future<List<Tag>> getTags({List<int>? ids}) async {
final results = await getCollection(
"/api/tags/?page=1&page_size=100000",
Tag.fromJson,
ErrorCode.tagLoadFailed,
minRequiredApiVersion: 2,
);
return results.where((element) => ids?.contains(element.id) ?? true).toList();
}
@override
Future<DocumentType?> getDocumentType(int id) async {
return getSingleResult(
"/api/document_types/$id/",
DocumentType.fromJson,
ErrorCode.documentTypeLoadFailed,
);
}
@override
Future<List<Correspondent>> getCorrespondents() {
return getCollection(
"/api/correspondents/?page=1&page_size=100000",
Correspondent.fromJson,
ErrorCode.correspondentLoadFailed,
);
}
@override
Future<List<DocumentType>> getDocumentTypes() {
return getCollection(
"/api/document_types/?page=1&page_size=100000",
DocumentType.fromJson,
ErrorCode.documentTypeLoadFailed,
);
}
@override
Future<Correspondent> saveCorrespondent(Correspondent correspondent) async {
final response = await httpClient.post(
Uri.parse('/api/correspondents/'),
body: json.encode(correspondent.toJson()),
headers: {"Content-Type": "application/json"},
);
if (response.statusCode == 201) {
return Correspondent.fromJson(json.decode(response.body));
}
throw ErrorMessage(ErrorCode.correspondentCreateFailed, httpStatusCode: response.statusCode);
}
@override
Future<DocumentType> saveDocumentType(DocumentType type) async {
final response = await httpClient.post(
Uri.parse('/api/document_types/'),
body: json.encode(type.toJson()),
headers: {"Content-Type": "application/json"},
);
if (response.statusCode == 201) {
return DocumentType.fromJson(json.decode(response.body));
}
throw const ErrorMessage(ErrorCode.documentTypeCreateFailed);
}
@override
Future<Tag> saveTag(Tag tag) async {
final body = json.encode(tag.toJson());
final response = await httpClient.post(Uri.parse('/api/tags/'), body: body, headers: {
"Content-Type": "application/json",
"Accept": "application/json; version=2",
});
if (response.statusCode == 201) {
return Tag.fromJson(json.decode(response.body));
}
throw const ErrorMessage(ErrorCode.tagCreateFailed);
}
@override
Future<int> getStatistics() async {
final response = await httpClient.get(Uri.parse('/api/statistics/'));
if (response.statusCode == 200) {
return json.decode(response.body)['documents_total'];
}
throw const ErrorMessage(ErrorCode.unknown);
}
@override
Future<int> deleteCorrespondent(Correspondent correspondent) async {
assert(correspondent.id != null);
final response = await httpClient.delete(Uri.parse('/api/correspondents/${correspondent.id}/'));
if (response.statusCode == 204) {
return correspondent.id!;
}
throw const ErrorMessage(ErrorCode.unknown);
}
@override
Future<int> deleteDocumentType(DocumentType documentType) async {
assert(documentType.id != null);
final response = await httpClient.delete(Uri.parse('/api/document_types/${documentType.id}/'));
if (response.statusCode == 204) {
return documentType.id!;
}
throw const ErrorMessage(ErrorCode.unknown);
}
@override
Future<int> deleteTag(Tag tag) async {
assert(tag.id != null);
final response = await httpClient.delete(Uri.parse('/api/tags/${tag.id}/'));
if (response.statusCode == 204) {
return tag.id!;
}
throw const ErrorMessage(ErrorCode.unknown);
}
@override
Future<Correspondent> updateCorrespondent(Correspondent correspondent) async {
assert(correspondent.id != null);
final response = await httpClient.put(
Uri.parse('/api/correspondents/${correspondent.id}/'),
headers: {"Content-Type": "application/json"},
body: json.encode(correspondent.toJson()),
);
if (response.statusCode == 200) {
return Correspondent.fromJson(json.decode(response.body));
}
throw const ErrorMessage(ErrorCode.unknown);
}
@override
Future<DocumentType> updateDocumentType(DocumentType documentType) async {
assert(documentType.id != null);
final response = await httpClient.put(
Uri.parse('/api/document_types/${documentType.id}/'),
headers: {"Content-Type": "application/json"},
body: json.encode(documentType.toJson()),
);
if (response.statusCode == 200) {
return DocumentType.fromJson(json.decode(response.body));
}
throw const ErrorMessage(ErrorCode.unknown);
}
@override
Future<Tag> updateTag(Tag tag) async {
assert(tag.id != null);
final response = await httpClient.put(
Uri.parse('/api/tags/${tag.id}/'),
headers: {
"Accept": "application/json; version=2",
"Content-Type": "application/json",
},
body: json.encode(tag.toJson()),
);
if (response.statusCode == 200) {
return Tag.fromJson(json.decode(response.body));
}
throw const ErrorMessage(ErrorCode.unknown);
}
@override
Future<int> deleteStoragePath(StoragePath path) async {
assert(path.id != null);
final response = await httpClient.delete(Uri.parse('/api/storage_paths/${path.id}/'));
if (response.statusCode == 204) {
return path.id!;
}
throw const ErrorMessage(ErrorCode.unknown);
}
@override
Future<StoragePath?> getStoragePath(int id) {
return getSingleResult("/api/storage_paths/?page=1&page_size=100000", StoragePath.fromJson,
ErrorCode.storagePathLoadFailed);
}
@override
Future<List<StoragePath>> getStoragePaths() {
return getCollection(
"/api/storage_paths/?page=1&page_size=100000",
StoragePath.fromJson,
ErrorCode.storagePathLoadFailed,
);
}
@override
Future<StoragePath> saveStoragePath(StoragePath path) async {
final response = await httpClient.post(
Uri.parse('/api/storage_paths/'),
body: json.encode(path.toJson()),
headers: {"Content-Type": "application/json"},
);
if (response.statusCode == 201) {
return StoragePath.fromJson(json.decode(response.body));
}
throw ErrorMessage(ErrorCode.storagePathCreateFailed, httpStatusCode: response.statusCode);
}
@override
Future<StoragePath> updateStoragePath(StoragePath path) async {
assert(path.id != null);
final response = await httpClient.put(
Uri.parse('/api/storage_paths/${path.id}/'),
headers: {"Content-Type": "application/json"},
body: json.encode(path.toJson()),
);
if (response.statusCode == 200) {
return StoragePath.fromJson(json.decode(response.body));
}
throw const ErrorMessage(ErrorCode.unknown);
}
}

View File

@@ -0,0 +1,32 @@
import 'package:flutter_paperless_mobile/features/labels/correspondent/model/correspondent.model.dart';
import 'package:flutter_paperless_mobile/features/labels/document_type/model/document_type.model.dart';
import 'package:flutter_paperless_mobile/features/labels/storage_path/model/storage_path.model.dart';
import 'package:flutter_paperless_mobile/features/labels/tags/model/tag.model.dart';
abstract class LabelRepository {
Future<Correspondent?> getCorrespondent(int id);
Future<List<Correspondent>> getCorrespondents();
Future<Correspondent> saveCorrespondent(Correspondent correspondent);
Future<Correspondent> updateCorrespondent(Correspondent correspondent);
Future<int> deleteCorrespondent(Correspondent correspondent);
Future<Tag?> getTag(int id);
Future<List<Tag>> getTags({List<int>? ids});
Future<Tag> saveTag(Tag tag);
Future<Tag> updateTag(Tag tag);
Future<int> deleteTag(Tag tag);
Future<DocumentType?> getDocumentType(int id);
Future<List<DocumentType>> getDocumentTypes();
Future<DocumentType> saveDocumentType(DocumentType type);
Future<DocumentType> updateDocumentType(DocumentType documentType);
Future<int> deleteDocumentType(DocumentType documentType);
Future<StoragePath?> getStoragePath(int id);
Future<List<StoragePath>> getStoragePaths();
Future<StoragePath> saveStoragePath(StoragePath path);
Future<StoragePath> updateStoragePath(StoragePath path);
Future<int> deleteStoragePath(StoragePath path);
Future<int> getStatistics();
}

View File

@@ -0,0 +1,23 @@
import 'package:flutter_paperless_mobile/core/bloc/label_cubit.dart';
import 'package:flutter_paperless_mobile/features/labels/correspondent/model/correspondent.model.dart';
import 'package:flutter_paperless_mobile/features/labels/storage_path/model/storage_path.model.dart';
import 'package:injectable/injectable.dart';
@singleton
class StoragePathCubit extends LabelCubit<StoragePath> {
StoragePathCubit(super.metaDataService);
@override
Future<void> initialize() async {
return labelRepository.getStoragePaths().then(loadFrom);
}
@override
Future<StoragePath> save(StoragePath item) => labelRepository.saveStoragePath(item);
@override
Future<StoragePath> update(StoragePath item) => labelRepository.updateStoragePath(item);
@override
Future<int> delete(StoragePath item) => labelRepository.deleteStoragePath(item);
}

View File

@@ -0,0 +1,64 @@
import 'package:flutter_paperless_mobile/core/type/json.dart';
import 'package:flutter_paperless_mobile/extensions/dart_extensions.dart';
import 'package:flutter_paperless_mobile/features/labels/document_type/model/matching_algorithm.dart';
import 'package:flutter_paperless_mobile/features/labels/model/label.model.dart';
class StoragePath extends Label {
static const pathKey = 'path';
late String? path;
StoragePath({
required super.id,
required super.name,
super.slug,
super.match,
super.matchingAlgorithm,
super.isInsensitive,
super.documentCount,
required this.path,
});
StoragePath.fromJson(JSON json)
: path = json[pathKey],
super.fromJson(json);
@override
String toString() {
return name;
}
@override
void addSpecificFieldsToJson(JSON json) {
json.tryPutIfAbsent(
pathKey,
() => path,
);
}
@override
StoragePath copyWith({
int? id,
String? name,
String? slug,
String? match,
MatchingAlgorithm? matchingAlgorithm,
bool? isInsensitive,
int? documentCount,
String? path,
}) {
return StoragePath(
id: id ?? this.id,
name: name ?? this.name,
documentCount: documentCount ?? documentCount,
isInsensitive: isInsensitive ?? isInsensitive,
path: path ?? this.path,
match: match ?? this.match,
matchingAlgorithm: matchingAlgorithm ?? this.matchingAlgorithm,
slug: slug ?? this.slug,
);
}
@override
String get queryEndpoint => 'storage_paths';
}

View File

@@ -0,0 +1,26 @@
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:flutter_paperless_mobile/features/labels/storage_path/bloc/storage_path_cubit.dart';
import 'package:flutter_paperless_mobile/features/labels/storage_path/model/storage_path.model.dart';
import 'package:flutter_paperless_mobile/features/labels/storage_path/view/widgets/storage_path_autofill_form_builder_field.dart';
import 'package:flutter_paperless_mobile/features/labels/view/pages/add_label_page.dart';
import 'package:flutter_paperless_mobile/generated/l10n.dart';
class AddStoragePathPage extends StatelessWidget {
final String? initalValue;
const AddStoragePathPage({Key? key, this.initalValue}) : super(key: key);
@override
Widget build(BuildContext context) {
return AddLabelPage<StoragePath>(
addLabelStr: S.of(context).addStoragePathPageTitle,
fromJson: StoragePath.fromJson,
cubit: BlocProvider.of<StoragePathCubit>(context),
initialName: initalValue,
additionalFields: const [
StoragePathAutofillFormBuilderField(name: StoragePath.pathKey),
SizedBox(height: 120.0),
],
);
}
}

View File

@@ -0,0 +1,48 @@
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:flutter_paperless_mobile/core/logic/error_code_localization_mapper.dart';
import 'package:flutter_paperless_mobile/core/model/error_message.dart';
import 'package:flutter_paperless_mobile/features/documents/bloc/documents_cubit.dart';
import 'package:flutter_paperless_mobile/features/documents/model/query_parameters/storage_path_query.dart';
import 'package:flutter_paperless_mobile/features/labels/storage_path/bloc/storage_path_cubit.dart';
import 'package:flutter_paperless_mobile/features/labels/storage_path/model/storage_path.model.dart';
import 'package:flutter_paperless_mobile/features/labels/storage_path/view/widgets/storage_path_autofill_form_builder_field.dart';
import 'package:flutter_paperless_mobile/features/labels/view/pages/edit_label_page.dart';
import 'package:flutter_paperless_mobile/util.dart';
class EditStoragePathPage extends StatelessWidget {
final StoragePath storagePath;
const EditStoragePathPage({super.key, required this.storagePath});
@override
Widget build(BuildContext context) {
return EditLabelPage<StoragePath>(
label: storagePath,
onSubmit: BlocProvider.of<StoragePathCubit>(context).replace,
onDelete: (correspondent) => _onDelete(correspondent, context),
fromJson: StoragePath.fromJson,
additionalFields: [
StoragePathAutofillFormBuilderField(
name: StoragePath.pathKey,
initialValue: storagePath.path,
),
const SizedBox(height: 120.0),
],
);
}
Future<void> _onDelete(StoragePath path, BuildContext context) async {
try {
await BlocProvider.of<StoragePathCubit>(context).remove(path);
final cubit = BlocProvider.of<DocumentsCubit>(context);
if (cubit.state.filter.storagePath.id == path.id) {
cubit.updateFilter(
filter: cubit.state.filter.copyWith(storagePath: const StoragePathQuery.unset()));
}
} on ErrorMessage catch (e) {
showSnackBar(context, translateError(context, e.code));
} finally {
Navigator.pop(context);
}
}
}

View File

@@ -0,0 +1,163 @@
import 'package:flutter/material.dart';
import 'package:flutter/src/widgets/container.dart';
import 'package:flutter/src/widgets/framework.dart';
import 'package:flutter_form_builder/flutter_form_builder.dart';
import 'package:flutter_paperless_mobile/features/labels/storage_path/model/storage_path.model.dart';
import 'package:flutter_paperless_mobile/generated/l10n.dart';
import 'package:form_builder_validators/form_builder_validators.dart';
class StoragePathAutofillFormBuilderField extends StatefulWidget {
final String name;
final String? initialValue;
const StoragePathAutofillFormBuilderField({
super.key,
required this.name,
this.initialValue,
});
@override
State<StoragePathAutofillFormBuilderField> createState() =>
_StoragePathAutofillFormBuilderFieldState();
}
class _StoragePathAutofillFormBuilderFieldState extends State<StoragePathAutofillFormBuilderField> {
late final TextEditingController _textEditingController;
late String _exampleOutput;
late bool _showClearIcon;
@override
void initState() {
super.initState();
_textEditingController = TextEditingController.fromValue(
TextEditingValue(text: widget.initialValue ?? ''),
)..addListener(() {
setState(() {
_showClearIcon = _textEditingController.text.isNotEmpty;
});
});
_exampleOutput = _buildExampleOutput(widget.initialValue ?? '');
_showClearIcon = widget.initialValue?.isNotEmpty ?? false;
}
@override
Widget build(BuildContext context) {
return FormBuilderField<String>(
name: widget.name,
initialValue: widget.initialValue ?? '',
builder: (field) => Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
TextFormField(
controller: _textEditingController,
validator: FormBuilderValidators.required(), //TODO: INTL
decoration: InputDecoration(
label: Text(S.of(context).documentStoragePathPropertyLabel),
suffixIcon: _showClearIcon
? IconButton(
icon: const Icon(Icons.clear),
onPressed: () => _resetfield(field),
)
: null),
onChanged: field.didChange,
),
const SizedBox(height: 8.0),
Text(
"Select to autofill path variable",
style: Theme.of(context).textTheme.caption,
),
Wrap(
alignment: WrapAlignment.start,
spacing: 8.0,
children: [
InputChip(
label: Text(S.of(context).documentArchiveSerialNumberPropertyLongLabel),
onPressed: () => _addParameterToInput("{asn}", field),
),
InputChip(
label: Text(S.of(context).documentCorrespondentPropertyLabel),
onPressed: () => _addParameterToInput("{correspondent}", field),
),
InputChip(
label: Text(S.of(context).documentDocumentTypePropertyLabel),
onPressed: () => _addParameterToInput("{document_type}", field),
),
InputChip(
label: Text(S.of(context).documentTagsPropertyLabel),
onPressed: () => _addParameterToInput("{tag_list}", field),
),
InputChip(
label: Text(S.of(context).documentTitlePropertyLabel),
onPressed: () => _addParameterToInput("{title}", field),
),
InputChip(
label: Text(S.of(context).documentCreatedPropertyLabel),
onPressed: () => _addParameterToInput("{created}", field),
),
InputChip(
label: Text(S.of(context).documentCreatedPropertyLabel +
" (${S.of(context).storagePathParameterYearLabel})"),
onPressed: () => _addParameterToInput("{created_year}", field),
),
InputChip(
label: Text(S.of(context).documentCreatedPropertyLabel +
" (${S.of(context).storagePathParameterMonthLabel})"),
onPressed: () => _addParameterToInput("{created_month}", field),
),
InputChip(
label: Text(S.of(context).documentCreatedPropertyLabel +
" (${S.of(context).storagePathParameterDayLabel})"),
onPressed: () => _addParameterToInput("{created_day}", field),
),
InputChip(
label: Text(S.of(context).documentCreatedPropertyLabel),
onPressed: () => _addParameterToInput("{added}", field),
),
InputChip(
label: Text(S.of(context).documentCreatedPropertyLabel +
" (${S.of(context).storagePathParameterYearLabel})"),
onPressed: () => _addParameterToInput("{added_year}", field),
),
InputChip(
label: Text(S.of(context).documentCreatedPropertyLabel +
" (${S.of(context).storagePathParameterMonthLabel})"),
onPressed: () => _addParameterToInput("{added_month}", field),
),
InputChip(
label: Text(S.of(context).documentCreatedPropertyLabel +
" (${S.of(context).storagePathParameterDayLabel})"),
onPressed: () => _addParameterToInput("{added_day}", field),
),
],
)
],
),
);
}
void _addParameterToInput(String param, FormFieldState<String> field) {
final text = (field.value ?? "") + param;
field.didChange(text);
_textEditingController.text = text;
}
String _buildExampleOutput(String input) {
return input
.replaceAll("{asn}", "1234")
.replaceAll("{correspondent}", "My Bank")
.replaceAll("{document_type}", "Invoice")
.replaceAll("{tag_list}", "TODO,University,Work")
.replaceAll("{created}", "2020-02-10")
.replaceAll("{created_year}", "2020")
.replaceAll("{created_month}", "02")
.replaceAll("{created_day}", "10")
.replaceAll("{added}", "2029-12-24")
.replaceAll("{added_year}", "2029")
.replaceAll("{added_month}", "12")
.replaceAll("{added_day}", "24");
}
void _resetfield(FormFieldState<String> field) {
field.didChange("");
_textEditingController.clear();
}
}

View File

@@ -0,0 +1,59 @@
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:flutter_paperless_mobile/di_initializer.dart';
import 'package:flutter_paperless_mobile/features/documents/bloc/documents_cubit.dart';
import 'package:flutter_paperless_mobile/features/documents/model/query_parameters/storage_path_query.dart';
import 'package:flutter_paperless_mobile/features/labels/storage_path/bloc/storage_path_cubit.dart';
import 'package:flutter_paperless_mobile/features/labels/storage_path/model/storage_path.model.dart';
class StoragePathWidget extends StatelessWidget {
final int? pathId;
final void Function()? afterSelected;
final Color? textColor;
final bool isClickable;
const StoragePathWidget({
Key? key,
required this.pathId,
this.afterSelected,
this.textColor,
this.isClickable = true,
}) : super(key: key);
@override
Widget build(BuildContext context) {
return AbsorbPointer(
absorbing: !isClickable,
child: BlocBuilder<StoragePathCubit, Map<int, StoragePath>>(
builder: (context, state) {
return GestureDetector(
onTap: () => _addStoragePathToFilter(context),
child: Text(
(state[pathId]?.name) ?? "-",
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: Theme.of(context).textTheme.bodyText2?.copyWith(
color: textColor ?? Theme.of(context).colorScheme.primary,
),
),
);
},
),
);
}
void _addStoragePathToFilter(BuildContext context) {
final cubit = getIt<DocumentsCubit>();
if (cubit.state.filter.correspondent.id == pathId) {
cubit.updateFilter(
filter: cubit.state.filter.copyWith(storagePath: const StoragePathQuery.unset()));
} else {
cubit.updateFilter(
filter: cubit.state.filter.copyWith(
storagePath: StoragePathQuery.fromId(pathId),
),
);
}
afterSelected?.call();
}
}

View File

@@ -0,0 +1,22 @@
import 'package:flutter_paperless_mobile/core/bloc/label_cubit.dart';
import 'package:flutter_paperless_mobile/features/labels/tags/model/tag.model.dart';
import 'package:injectable/injectable.dart';
@singleton
class TagCubit extends LabelCubit<Tag> {
TagCubit(super.metaDataService);
@override
Future<void> initialize() async {
return labelRepository.getTags().then(loadFrom);
}
@override
Future<Tag> save(Tag item) => labelRepository.saveTag(item);
@override
Future<Tag> update(Tag item) => labelRepository.updateTag(item);
@override
Future<int> delete(Tag item) => labelRepository.deleteTag(item);
}

View File

@@ -0,0 +1,96 @@
import 'dart:developer';
import 'dart:ui';
import 'package:flutter_paperless_mobile/core/type/json.dart';
import 'package:flutter_paperless_mobile/extensions/dart_extensions.dart';
import 'package:flutter_paperless_mobile/features/labels/document_type/model/matching_algorithm.dart';
import 'package:flutter_paperless_mobile/features/labels/model/label.model.dart';
class Tag extends Label {
static const colorKey = 'color';
static const isInboxTagKey = 'is_inbox_tag';
static const textColorKey = 'text_color';
final Color? color;
final Color? textColor;
final bool? isInboxTag;
Tag({
required super.id,
required super.name,
super.documentCount,
super.isInsensitive,
super.match,
super.matchingAlgorithm,
super.slug,
this.color,
this.textColor,
this.isInboxTag,
});
Tag.fromJson(JSON json)
: isInboxTag = json[isInboxTagKey],
textColor = Color(_colorStringToInt(json[textColorKey]) ?? 0),
color = (json[colorKey] is Color)
? json[colorKey]
: Color(_colorStringToInt(json[colorKey]) ?? 0),
super.fromJson(json);
@override
String toString() {
return name;
}
@override
void addSpecificFieldsToJson(JSON json) {
json.tryPutIfAbsent(colorKey, () => _toHex(color));
json.tryPutIfAbsent(isInboxTagKey, () => isInboxTag);
}
@override
Tag copyWith({
int? id,
String? name,
String? match,
MatchingAlgorithm? matchingAlgorithm,
bool? isInsensitive,
int? documentCount,
String? slug,
Color? color,
Color? textColor,
bool? isInboxTag,
}) {
return Tag(
id: id ?? this.id,
name: name ?? this.name,
match: match ?? this.match,
matchingAlgorithm: matchingAlgorithm ?? this.matchingAlgorithm,
isInsensitive: isInsensitive ?? this.isInsensitive,
documentCount: documentCount ?? this.documentCount,
slug: slug ?? this.slug,
color: color ?? this.color,
textColor: textColor ?? this.textColor,
isInboxTag: isInboxTag ?? this.isInboxTag,
);
}
@override
String get queryEndpoint => 'tags';
}
///
/// Taken from [FormBuilderColorPicker].
///
String? _toHex(Color? color) {
if (color == null) {
return null;
}
String val = '#${(color.value & 0xFFFFFF).toRadixString(16).padLeft(6, '0').toLowerCase()}';
log("Color in Tag#_toHex is $val");
return val;
}
int? _colorStringToInt(String? color) {
if (color == null) return null;
return int.tryParse(color.replaceAll("#", "ff"), radix: 16);
}

View File

@@ -0,0 +1,35 @@
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:flutter_form_builder/flutter_form_builder.dart';
import 'package:flutter_paperless_mobile/features/labels/tags/bloc/tags_cubit.dart';
import 'package:flutter_paperless_mobile/features/labels/tags/model/tag.model.dart';
import 'package:flutter_paperless_mobile/features/labels/view/pages/add_label_page.dart';
import 'package:flutter_paperless_mobile/generated/l10n.dart';
import 'package:form_builder_extra_fields/form_builder_extra_fields.dart';
class AddTagPage extends StatelessWidget {
const AddTagPage({Key? key}) : super(key: key);
@override
Widget build(BuildContext context) {
return AddLabelPage<Tag>(
addLabelStr: S.of(context).addTagPageTitle,
fromJson: Tag.fromJson,
cubit: BlocProvider.of<TagCubit>(context),
additionalFields: [
FormBuilderColorPickerField(
name: Tag.colorKey,
valueTransformer: (color) => "#${color?.value.toRadixString(16)}",
decoration: InputDecoration(
label: Text(S.of(context).tagColorPropertyLabel),
),
colorPickerType: ColorPickerType.materialPicker,
),
FormBuilderCheckbox(
name: Tag.isInboxTagKey,
title: Text(S.of(context).tagInboxTagPropertyLabel),
),
],
);
}
}

View File

@@ -0,0 +1,61 @@
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:flutter_form_builder/flutter_form_builder.dart';
import 'package:flutter_paperless_mobile/core/model/error_message.dart';
import 'package:flutter_paperless_mobile/features/documents/bloc/documents_cubit.dart';
import 'package:flutter_paperless_mobile/features/documents/model/document_filter.dart';
import 'package:flutter_paperless_mobile/features/documents/model/query_parameters/tags_query.dart';
import 'package:flutter_paperless_mobile/features/labels/tags/bloc/tags_cubit.dart';
import 'package:flutter_paperless_mobile/features/labels/tags/model/tag.model.dart';
import 'package:flutter_paperless_mobile/features/labels/view/pages/edit_label_page.dart';
import 'package:flutter_paperless_mobile/generated/l10n.dart';
import 'package:flutter_paperless_mobile/util.dart';
import 'package:form_builder_extra_fields/form_builder_extra_fields.dart';
class EditTagPage extends StatelessWidget {
final Tag tag;
const EditTagPage({super.key, required this.tag});
@override
Widget build(BuildContext context) {
return EditLabelPage<Tag>(
label: tag,
onSubmit: BlocProvider.of<TagCubit>(context).replace,
onDelete: (tag) => _onDelete(tag, context),
fromJson: Tag.fromJson,
additionalFields: [
FormBuilderColorPickerField(
initialValue: tag.color,
name: Tag.colorKey,
decoration: InputDecoration(
label: Text(S.of(context).tagColorPropertyLabel),
),
colorPickerType: ColorPickerType.blockPicker,
),
FormBuilderCheckbox(
initialValue: tag.isInboxTag,
name: Tag.isInboxTagKey,
title: Text(S.of(context).tagInboxTagPropertyLabel),
),
],
);
}
Future<void> _onDelete(Tag tag, BuildContext context) async {
try {
await BlocProvider.of<TagCubit>(context).remove(tag);
final cubit = BlocProvider.of<DocumentsCubit>(context);
final currentFilter = cubit.state.filter;
late DocumentFilter updatedFilter = currentFilter;
if (currentFilter.tags.ids.contains(tag.id)) {
updatedFilter = currentFilter.copyWith(
tags: TagsQuery.fromIds(
currentFilter.tags.ids.where((tagId) => tagId != tag.id).toList()));
}
cubit.updateFilter(filter: updatedFilter);
} on ErrorMessage catch (error) {
showError(context, error);
}
}
}

View File

@@ -0,0 +1 @@

View File

@@ -0,0 +1,55 @@
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:flutter_paperless_mobile/features/documents/bloc/documents_cubit.dart';
import 'package:flutter_paperless_mobile/features/documents/bloc/documents_state.dart';
import 'package:flutter_paperless_mobile/features/documents/model/query_parameters/tags_query.dart';
import 'package:flutter_paperless_mobile/features/labels/tags/model/tag.model.dart';
class TagWidget extends StatelessWidget {
final Tag tag;
final void Function()? afterTagTapped;
const TagWidget({super.key, required this.tag, required this.afterTagTapped});
@override
Widget build(BuildContext context) {
return Padding(
padding: const EdgeInsets.only(right: 4.0),
child: BlocBuilder<DocumentsCubit, DocumentsState>(
builder: (context, state) {
return FilterChip(
selected: state.filter.tags.ids.contains(tag.id),
selectedColor: tag.color,
onSelected: (_) => _addTagToFilter(context),
visualDensity: const VisualDensity(vertical: -2),
materialTapTargetSize: MaterialTapTargetSize.shrinkWrap,
label: Text(
tag.name,
style: TextStyle(color: tag.textColor),
),
backgroundColor: tag.color,
side: BorderSide.none,
);
},
),
);
}
void _addTagToFilter(BuildContext context) {
final cubit = BlocProvider.of<DocumentsCubit>(context);
if (cubit.state.filter.tags.ids.contains(tag.id)) {
cubit.updateFilter(
filter: cubit.state.filter.copyWith(
tags: TagsQuery.fromIds(cubit.state.filter.tags.ids.where((id) => id != tag.id).toList()),
),
);
} else {
cubit.updateFilter(
filter: cubit.state.filter
.copyWith(tags: TagsQuery.fromIds([...cubit.state.filter.tags.ids, tag.id!])),
);
}
if (afterTagTapped != null) {
afterTagTapped!();
}
}
}

View File

@@ -0,0 +1,99 @@
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:flutter_form_builder/flutter_form_builder.dart';
import 'package:flutter_paperless_mobile/extensions/flutter_extensions.dart';
import 'package:flutter_paperless_mobile/features/documents/model/query_parameters/tags_query.dart';
import 'package:flutter_paperless_mobile/features/labels/tags/bloc/tags_cubit.dart';
import 'package:flutter_paperless_mobile/features/labels/tags/model/tag.model.dart';
import 'package:flutter_paperless_mobile/generated/l10n.dart';
class TagFormField extends StatefulWidget {
final TagsQuery? initialValue;
final String name;
const TagFormField({
super.key,
required this.name,
this.initialValue,
});
@override
State<TagFormField> createState() => _TagFormFieldState();
}
class _TagFormFieldState extends State<TagFormField> {
@override
Widget build(BuildContext context) {
return BlocBuilder<TagCubit, Map<int, Tag>>(
builder: (context, tagState) {
return FormBuilderField<TagsQuery>(
builder: (field) {
final sortedTags = tagState.values.toList()
..sort(
(a, b) => a.name.compareTo(b.name),
);
//TODO: this is either not correctly resetting on filter reset or (when adding UniqueKey to FormField or ChipsInput) unmounts widget.
// return ChipsInput<int>(
// chipBuilder: (context, state, data) => Chip(
// onDeleted: () => state.deleteChip(data),
// shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8)),
// backgroundColor: Color(tagState[data]!.color ?? Colors.white.value),
// label: Text(
// tagState[data]!.name,
// style: TextStyle(color: Color(tagState[data]!.textColor ?? Colors.black.value)),
// ),
// ),
// suggestionBuilder: (context, state, data) => ListTile(
// title: Text(tagState[data]!.name),
// textColor: Color(tagState[data]!.textColor!),
// tileColor: Color(tagState[data]!.color!),
// onTap: () => state.selectSuggestion(data),
// ),
// findSuggestions: (query) => tagState.values
// .where((element) => element.name.toLowerCase().startsWith(query.toLowerCase()))
// .map((e) => e.id!)
// .toList(),
// onChanged: (tags) => field.didChange(tags),
// initialValue: field.value!,
// );
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
S.of(context).documentTagsPropertyLabel,
),
Wrap(
children: sortedTags
.map((tag) => FilterChip(
label: Text(
tag.name,
style: TextStyle(
color: tag.textColor,
),
),
selectedColor: tag.color,
selected: field.value?.ids.contains(tag.id) ?? false,
onSelected: (isSelected) {
List<int> ids = [...field.value?.ids ?? []];
if (isSelected) {
ids.add(tag.id!);
} else {
ids.remove(tag.id);
}
field.didChange(TagsQuery.fromIds(ids));
},
backgroundColor: tag.color,
))
.toList()
.padded(const EdgeInsets.only(right: 4.0)),
),
],
);
},
initialValue: widget.initialValue ?? const TagsQuery.unset(),
name: widget.name,
);
},
);
}
}

View File

@@ -0,0 +1,55 @@
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:flutter_paperless_mobile/features/labels/tags/bloc/tags_cubit.dart';
import 'package:flutter_paperless_mobile/features/labels/tags/model/tag.model.dart';
import 'package:flutter_paperless_mobile/features/labels/tags/view/widgets/tag_widget.dart';
class TagsWidget extends StatefulWidget {
final List<int> tagIds;
final bool isMultiLine;
final void Function()? afterTagTapped;
const TagsWidget({
Key? key,
required this.tagIds,
this.afterTagTapped,
this.isMultiLine = true,
}) : super(key: key);
@override
State<TagsWidget> createState() => _TagsWidgetState();
}
class _TagsWidgetState extends State<TagsWidget> {
@override
Widget build(BuildContext context) {
return BlocBuilder<TagCubit, Map<int, Tag>>(
builder: (context, state) {
final children = widget.tagIds
.where((id) => state.containsKey(id))
.map(
(id) => TagWidget(
tag: state[id]!,
afterTagTapped: widget.afterTagTapped,
),
)
.toList();
if (widget.isMultiLine) {
return Wrap(
runAlignment: WrapAlignment.start,
children: children,
runSpacing: 8,
spacing: 4,
);
} else {
return SingleChildScrollView(
scrollDirection: Axis.horizontal,
child: Row(
children: children,
),
);
}
},
);
}
}

View File

@@ -0,0 +1,114 @@
import 'dart:developer';
import 'package:flutter/material.dart';
import 'package:flutter_form_builder/flutter_form_builder.dart';
import 'package:flutter_paperless_mobile/core/bloc/label_cubit.dart';
import 'package:flutter_paperless_mobile/core/logic/error_code_localization_mapper.dart';
import 'package:flutter_paperless_mobile/core/model/error_message.dart';
import 'package:flutter_paperless_mobile/core/type/json.dart';
import 'package:flutter_paperless_mobile/extensions/flutter_extensions.dart';
import 'package:flutter_paperless_mobile/features/labels/document_type/model/matching_algorithm.dart';
import 'package:flutter_paperless_mobile/features/labels/model/label.model.dart';
import 'package:flutter_paperless_mobile/generated/l10n.dart';
import 'package:flutter_paperless_mobile/util.dart';
import 'package:form_builder_validators/form_builder_validators.dart';
class AddLabelPage<T extends Label> extends StatefulWidget {
final String? initialName;
final String addLabelStr;
final T Function(Map<String, dynamic> json) fromJson;
final LabelCubit<T> cubit;
final List<Widget> additionalFields;
const AddLabelPage({
Key? key,
this.initialName,
required this.addLabelStr,
required this.fromJson,
required this.cubit,
this.additionalFields = const [],
}) : super(key: key);
@override
State<AddLabelPage> createState() => _AddLabelPageState<T>();
}
class _AddLabelPageState<T extends Label> extends State<AddLabelPage<T>> {
final _formKey = GlobalKey<FormBuilderState>();
Map<String, String> _errors = {};
@override
Widget build(BuildContext context) {
return Scaffold(
resizeToAvoidBottomInset: false,
appBar: AppBar(
title: Text(widget.addLabelStr),
),
floatingActionButton: FloatingActionButton.extended(
icon: const Icon(Icons.add),
label: Text(S.of(context).genericActionCreateLabel),
onPressed: _onSubmit,
),
body: FormBuilder(
key: _formKey,
child: ListView(
children: [
FormBuilderTextField(
autovalidateMode: AutovalidateMode.onUserInteraction,
name: Label.nameKey,
decoration: InputDecoration(
labelText: S.of(context).labelNamePropertyLabel,
errorText: _errors[Label.nameKey],
),
initialValue: widget.initialName,
validator: FormBuilderValidators.required(),
onChanged: (val) => setState(() => _errors = {}),
),
FormBuilderTextField(
autovalidateMode: AutovalidateMode.onUserInteraction,
name: Label.matchKey,
decoration: InputDecoration(
labelText: S.of(context).labelMatchPropertyLabel,
),
onChanged: (val) => setState(() => _errors = {}),
),
FormBuilderDropdown<int?>(
name: Label.matchingAlgorithmKey,
initialValue: MatchingAlgorithm.anyWord.value,
decoration: InputDecoration(
labelText: S.of(context).labelMatchingAlgorithmPropertyLabel,
errorText: _errors[Label.matchingAlgorithmKey],
),
onChanged: (val) => setState(() => _errors = {}),
items: MatchingAlgorithm.values
.map((algo) => DropdownMenuItem<int?>(
child: Text(algo.name), //TODO: INTL
value: algo.value))
.toList(),
),
FormBuilderCheckbox(
name: Label.isInsensitiveKey,
initialValue: true,
title: Text(S.of(context).labelIsInsensivitePropertyLabel),
),
...widget.additionalFields,
].padded(),
),
),
);
}
void _onSubmit() async {
log("IsValid? ${_formKey.currentState?.isValid}");
if (_formKey.currentState?.saveAndValidate() ?? false) {
try {
final label = await widget.cubit.add(widget.fromJson(_formKey.currentState!.value));
Navigator.pop(context, label);
} on ErrorMessage catch (e) {
showSnackBar(context, translateError(context, e.code));
} on Map<String, String> catch (json) {
setState(() => _errors = json);
}
}
}
}

View File

@@ -0,0 +1,124 @@
import 'dart:async';
import 'package:flutter/material.dart';
import 'package:flutter_form_builder/flutter_form_builder.dart';
import 'package:flutter_paperless_mobile/core/logic/error_code_localization_mapper.dart';
import 'package:flutter_paperless_mobile/core/model/error_message.dart';
import 'package:flutter_paperless_mobile/core/type/json.dart';
import 'package:flutter_paperless_mobile/extensions/flutter_extensions.dart';
import 'package:flutter_paperless_mobile/features/labels/document_type/model/matching_algorithm.dart';
import 'package:flutter_paperless_mobile/features/labels/model/label.model.dart';
import 'package:flutter_paperless_mobile/generated/l10n.dart';
import 'package:flutter_paperless_mobile/util.dart';
import 'package:form_builder_validators/form_builder_validators.dart';
class EditLabelPage<T extends Label> extends StatefulWidget {
final T label;
final Future<void> Function(T) onSubmit;
final Future<void> Function(T) onDelete;
final T Function(JSON) fromJson;
final List<Widget> additionalFields;
const EditLabelPage({
Key? key,
required this.label,
required this.fromJson,
required this.onSubmit,
required this.onDelete,
this.additionalFields = const [],
}) : super(key: key);
@override
State<EditLabelPage> createState() => _EditLabelPageState<T>();
}
class _EditLabelPageState<T extends Label> extends State<EditLabelPage<T>> {
final _formKey = GlobalKey<FormBuilderState>();
Map<String, String> _errors = {};
@override
Widget build(BuildContext context) {
return Scaffold(
resizeToAvoidBottomInset: false,
appBar: AppBar(
title: Text(S.of(context).genericActionEditLabel),
actions: [
IconButton(
onPressed: () => widget.onDelete(widget.label),
icon: const Icon(Icons.delete),
),
],
),
floatingActionButton: FloatingActionButton.extended(
icon: const Icon(Icons.add),
label: Text(S.of(context).genericActionUpdateLabel),
onPressed: _onSubmit,
),
body: FormBuilder(
key: _formKey,
child: ListView(
children: [
FormBuilderTextField(
name: Label.nameKey,
decoration: InputDecoration(
labelText: S.of(context).labelNamePropertyLabel,
errorText: _errors[Label.nameKey],
),
validator: FormBuilderValidators.required(),
initialValue: widget.label.name,
onChanged: (val) => setState(() => _errors = {}),
),
FormBuilderTextField(
name: Label.matchKey,
decoration: InputDecoration(
labelText: S.of(context).labelMatchPropertyLabel,
errorText: _errors[Label.matchKey],
),
initialValue: widget.label.match,
onChanged: (val) => setState(() => _errors = {}),
),
FormBuilderDropdown<int?>(
name: Label.matchingAlgorithmKey,
initialValue:
widget.label.matchingAlgorithm?.value ?? MatchingAlgorithm.allWords.value,
decoration: InputDecoration(
labelText: S.of(context).labelMatchingAlgorithmPropertyLabel,
errorText: _errors[Label.matchingAlgorithmKey],
),
onChanged: (val) => setState(() => _errors = {}),
items: MatchingAlgorithm.values
.map(
(algo) => DropdownMenuItem<int?>(
child: Text(algo.name), //TODO: INTL
value: algo.value,
),
)
.toList(),
),
FormBuilderCheckbox(
name: Label.isInsensitiveKey,
initialValue: widget.label.isInsensitive,
title: Text(S.of(context).labelIsInsensivitePropertyLabel),
),
...widget.additionalFields,
].padded(),
),
),
);
}
void _onSubmit() async {
if (_formKey.currentState?.saveAndValidate() ?? false) {
try {
final mergedJson = {...widget.label.toJson(), ..._formKey.currentState!.value};
await widget.onSubmit(widget.fromJson(mergedJson));
Navigator.pop(context);
} on ErrorMessage catch (e) {
showSnackBar(context, translateError(context, e.code));
} on Map<String, String> catch (errorMessages) {
setState(() => _errors = errorMessages);
}
}
}
}

View File

@@ -0,0 +1,237 @@
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:flutter_paperless_mobile/core/bloc/label_bloc_provider.dart';
import 'package:flutter_paperless_mobile/di_initializer.dart';
import 'package:flutter_paperless_mobile/features/documents/bloc/documents_cubit.dart';
import 'package:flutter_paperless_mobile/features/documents/model/query_parameters/correspondent_query.dart';
import 'package:flutter_paperless_mobile/features/documents/model/query_parameters/document_type_query.dart';
import 'package:flutter_paperless_mobile/features/documents/model/query_parameters/storage_path_query.dart';
import 'package:flutter_paperless_mobile/features/documents/model/query_parameters/tags_query.dart';
import 'package:flutter_paperless_mobile/features/labels/correspondent/bloc/correspondents_cubit.dart';
import 'package:flutter_paperless_mobile/features/labels/correspondent/view/pages/edit_correspondent_page.dart';
import 'package:flutter_paperless_mobile/features/labels/document_type/bloc/document_type_cubit.dart';
import 'package:flutter_paperless_mobile/features/documents/model/document_filter.dart';
import 'package:flutter_paperless_mobile/features/home/view/widget/info_drawer.dart';
import 'package:flutter_paperless_mobile/features/labels/correspondent/model/correspondent.model.dart';
import 'package:flutter_paperless_mobile/features/labels/correspondent/view/pages/add_correspondent_page.dart';
import 'package:flutter_paperless_mobile/features/labels/document_type/model/document_type.model.dart';
import 'package:flutter_paperless_mobile/features/labels/document_type/view/pages/add_document_type_page.dart';
import 'package:flutter_paperless_mobile/features/labels/document_type/view/pages/edit_document_type_page.dart';
import 'package:flutter_paperless_mobile/features/labels/model/label.model.dart';
import 'package:flutter_paperless_mobile/features/labels/storage_path/bloc/storage_path_cubit.dart';
import 'package:flutter_paperless_mobile/features/labels/storage_path/model/storage_path.model.dart';
import 'package:flutter_paperless_mobile/features/labels/storage_path/view/pages/add_storage_path_page.dart';
import 'package:flutter_paperless_mobile/features/labels/storage_path/view/pages/edit_storage_path_page.dart';
import 'package:flutter_paperless_mobile/features/labels/tags/model/tag.model.dart';
import 'package:flutter_paperless_mobile/features/labels/tags/view/pages/add_tag_page.dart';
import 'package:flutter_paperless_mobile/features/labels/tags/view/pages/edit_tag_page.dart';
import 'package:flutter_paperless_mobile/features/labels/view/widgets/label_item.dart';
import 'package:flutter_paperless_mobile/features/labels/view/widgets/label_tab_view.dart';
import 'package:flutter_paperless_mobile/features/labels/tags/bloc/tags_cubit.dart';
import 'package:flutter_paperless_mobile/generated/l10n.dart';
class LabelsPage extends StatefulWidget {
const LabelsPage({Key? key}) : super(key: key);
@override
State<LabelsPage> createState() => _LabelsPageState();
}
class _LabelsPageState extends State<LabelsPage> with SingleTickerProviderStateMixin {
late final TabController _tabController;
int _currentIndex = 0;
@override
void initState() {
super.initState();
BlocProvider.of<CorrespondentCubit>(context).initialize();
BlocProvider.of<DocumentTypeCubit>(context).initialize();
BlocProvider.of<TagCubit>(context).initialize();
_tabController = TabController(length: 4, vsync: this)
..addListener(() => setState(() => _currentIndex = _tabController.index));
}
@override
Widget build(BuildContext context) {
return DefaultTabController(
length: 3,
child: Scaffold(
drawer: const InfoDrawer(),
appBar: AppBar(
title: Text(
[
S.of(context).labelsPageCorrespondentsTitleText,
S.of(context).labelsPageDocumentTypesTitleText,
S.of(context).labelsPageTagsTitleText,
S.of(context).labelsPageStoragePathTitleText
][_currentIndex],
),
actions: [
IconButton(
onPressed: _onAddPressed,
icon: const Icon(Icons.add),
)
],
bottom: PreferredSize(
preferredSize: const Size.fromHeight(kToolbarHeight),
child: ColoredBox(
color: Theme.of(context).bottomAppBarColor,
child: TabBar(
indicatorColor: Theme.of(context).colorScheme.primary,
controller: _tabController,
tabs: [
Tab(
icon: Icon(
Icons.person_outline,
color: Theme.of(context).colorScheme.onPrimaryContainer,
),
),
Tab(
icon: Icon(
Icons.description_outlined,
color: Theme.of(context).colorScheme.onPrimaryContainer,
),
),
Tab(
icon: Icon(
Icons.label_outline,
color: Theme.of(context).colorScheme.onPrimaryContainer,
),
),
Tab(
icon: Icon(
Icons.folder_open,
color: Theme.of(context).colorScheme.onPrimaryContainer,
),
)
],
),
),
),
),
body: TabBarView(
controller: _tabController,
children: [
LabelTabView<Correspondent>(
cubit: BlocProvider.of<CorrespondentCubit>(context),
filterBuilder: (label) => DocumentFilter(
correspondent: CorrespondentQuery.fromId(label.id),
pageSize: label.documentCount ?? 0,
),
onOpenEditPage: _openEditCorrespondentPage,
),
LabelTabView<DocumentType>(
cubit: BlocProvider.of<DocumentTypeCubit>(context),
filterBuilder: (label) => DocumentFilter(
documentType: DocumentTypeQuery.fromId(label.id),
pageSize: label.documentCount ?? 0,
),
onOpenEditPage: _openEditDocumentTypePage,
),
LabelTabView<Tag>(
cubit: BlocProvider.of<TagCubit>(context),
filterBuilder: (label) => DocumentFilter(
tags: TagsQuery.fromIds([label.id!]),
pageSize: label.documentCount ?? 0,
),
onOpenEditPage: _openEditTagPage,
leadingBuilder: (t) => CircleAvatar(backgroundColor: t.color),
),
LabelTabView<StoragePath>(
cubit: BlocProvider.of<StoragePathCubit>(context),
onOpenEditPage: _openEditStoragePathPage,
filterBuilder: (label) => DocumentFilter(
storagePath: StoragePathQuery.fromId(label.id),
pageSize: label.documentCount ?? 0,
),
contentBuilder: (path) => Text(path.path ?? ""),
),
],
),
),
);
}
void _openEditCorrespondentPage(Correspondent correspondent) {
Navigator.push(
context,
MaterialPageRoute(
builder: (_) => MultiBlocProvider(
providers: [
BlocProvider.value(value: getIt<DocumentsCubit>()),
BlocProvider.value(value: BlocProvider.of<CorrespondentCubit>(context)),
],
child: EditCorrespondentPage(correspondent: correspondent),
),
),
);
}
void _openEditDocumentTypePage(DocumentType docType) {
Navigator.push(
context,
MaterialPageRoute(
builder: (_) => MultiBlocProvider(
providers: [
BlocProvider.value(value: getIt<DocumentsCubit>()),
BlocProvider.value(value: BlocProvider.of<DocumentTypeCubit>(context)),
],
child: EditDocumentTypePage(documentType: docType),
),
),
);
}
void _openEditTagPage(Tag tag) {
Navigator.push(
context,
MaterialPageRoute(
builder: (_) => MultiBlocProvider(
providers: [
BlocProvider.value(value: getIt<DocumentsCubit>()),
BlocProvider.value(value: BlocProvider.of<TagCubit>(context)),
],
child: EditTagPage(tag: tag),
),
),
);
}
void _openEditStoragePathPage(StoragePath path) {
Navigator.push(
context,
MaterialPageRoute(
builder: (_) => MultiBlocProvider(
providers: [
BlocProvider.value(value: getIt<DocumentsCubit>()),
BlocProvider.value(value: BlocProvider.of<StoragePathCubit>(context)),
],
child: EditStoragePathPage(storagePath: path),
),
),
);
}
void _onAddPressed() {
Navigator.push(context, MaterialPageRoute(
builder: (context) {
late final Widget page;
switch (_currentIndex) {
case 0:
page = const AddCorrespondentPage();
break;
case 1:
page = const AddDocumentTypePage();
break;
case 2:
page = const AddTagPage();
break;
case 3:
page = const AddStoragePathPage();
}
return LabelBlocProvider(child: page);
},
));
}
}

View File

@@ -0,0 +1,161 @@
import 'package:flutter/material.dart';
import 'package:flutter_form_builder/flutter_form_builder.dart';
import 'package:flutter_paperless_mobile/features/documents/model/query_parameters/id_query_parameter.dart';
import 'package:flutter_paperless_mobile/features/labels/correspondent/model/correspondent.model.dart';
import 'package:flutter_paperless_mobile/features/labels/document_type/model/document_type.model.dart';
import 'package:flutter_paperless_mobile/features/labels/model/label.model.dart';
import 'package:flutter_paperless_mobile/generated/l10n.dart';
import 'package:form_builder_extra_fields/form_builder_extra_fields.dart';
///
/// Form field allowing to select labels (i.e. correspondent, documentType)
/// [T] is the label (model) type, [R] is the return type.
///
class LabelFormField<T extends Label, R extends IdQueryParameter> extends StatefulWidget {
final Widget prefixIcon;
final Map<int, T> state;
final FormBuilderState? formBuilderState;
final IdQueryParameter? initialValue;
final String name;
final String label;
final FormFieldValidator? validator;
final Widget Function(String)? labelCreationWidgetBuilder;
final R Function() queryParameterNotAssignedBuilder;
final R Function(int? id) queryParameterIdBuilder;
final bool notAssignedSelectable;
const LabelFormField({
Key? key,
required this.name,
required this.state,
this.validator,
this.initialValue,
required this.label,
this.labelCreationWidgetBuilder,
required this.queryParameterNotAssignedBuilder,
required this.queryParameterIdBuilder,
required this.formBuilderState,
required this.prefixIcon,
this.notAssignedSelectable = true,
}) : super(key: key);
@override
State<LabelFormField<T, R>> createState() => _LabelFormFieldState<T, R>();
}
class _LabelFormFieldState<T extends Label, R extends IdQueryParameter>
extends State<LabelFormField<T, R>> {
bool _showCreationSuffixIcon = false;
late bool _showClearSuffixIcon;
late final TextEditingController _textEditingController;
@override
void initState() {
super.initState();
_showClearSuffixIcon = widget.state.containsKey(widget.initialValue?.id);
_textEditingController =
TextEditingController(text: widget.state[widget.initialValue?.id]?.name ?? '')
..addListener(() {
setState(() {
_showCreationSuffixIcon = widget.state.values
.where((item) => item.name.toLowerCase().startsWith(
_textEditingController.text.toLowerCase(),
))
.isEmpty;
});
setState(() => _showClearSuffixIcon = _textEditingController.text.isNotEmpty);
});
}
@override
Widget build(BuildContext context) {
return FormBuilderTypeAhead<IdQueryParameter>(
initialValue: widget.initialValue ?? widget.queryParameterIdBuilder(null),
name: widget.name,
itemBuilder: (context, suggestion) => ListTile(
title: Text(widget.state[suggestion.id]?.name ?? S.of(context).labelNotAssignedText),
),
suggestionsCallback: (pattern) {
final List<IdQueryParameter> suggestions = widget.state.keys
.where((item) =>
widget.state[item]!.name.toLowerCase().startsWith(pattern.toLowerCase()) ||
pattern.isEmpty)
.map((id) => widget.queryParameterIdBuilder(id))
.toList();
if (widget.notAssignedSelectable) {
suggestions.insert(0, widget.queryParameterNotAssignedBuilder());
}
return suggestions;
},
onChanged: (value) {
setState(() => _showClearSuffixIcon = value?.isSet ?? false);
},
controller: _textEditingController,
decoration: InputDecoration(
prefixIcon: widget.prefixIcon,
label: Text(widget.label),
hintText: _getLocalizedHint(context),
suffixIcon: _buildSuffixIcon(context),
),
selectionToTextTransformer: (suggestion) {
if (suggestion == widget.queryParameterNotAssignedBuilder()) {
return S.of(context).labelNotAssignedText;
}
return widget.state[suggestion.id]?.name ?? "";
},
direction: AxisDirection.up,
onSuggestionSelected: (suggestion) =>
widget.formBuilderState?.fields[widget.name]?.didChange(suggestion as R),
);
}
Widget? _buildSuffixIcon(BuildContext context) {
if (_showCreationSuffixIcon && widget.labelCreationWidgetBuilder != null) {
return IconButton(
onPressed: () => Navigator.of(context)
.push<T>(MaterialPageRoute(
builder: (context) =>
widget.labelCreationWidgetBuilder!(_textEditingController.text)))
.then((value) {
if (value != null) {
// If new label has been created, set form field value and text of this form field and unfocus keyboard (we assume user is done).
widget.formBuilderState?.fields[widget.name]
?.didChange(widget.queryParameterIdBuilder(value.id));
_textEditingController.text = value.name;
FocusScope.of(context).unfocus();
} else {
_reset();
}
}),
icon: const Icon(
Icons.new_label,
),
);
}
if (_showClearSuffixIcon) {
return IconButton(
icon: const Icon(Icons.clear),
onPressed: _reset,
);
}
return null;
}
void _reset() {
widget.formBuilderState?.fields[widget.name]?.didChange(widget.queryParameterIdBuilder(null));
_textEditingController.clear();
}
String _getLocalizedHint(BuildContext context) {
if (T == Correspondent) {
return S.of(context).correspondentFormFieldSearchHintText;
} else if (T == DocumentType) {
return S.of(context).documentTypeFormFieldSearchHintText;
} else {
return S
.of(context)
.tagFormFieldSearchHintText; //TODO: Update tag form field once there is multi selection support.
}
}
}

View File

@@ -0,0 +1,70 @@
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:flutter_paperless_mobile/core/bloc/label_bloc_provider.dart';
import 'package:flutter_paperless_mobile/di_initializer.dart';
import 'package:flutter_paperless_mobile/features/documents/bloc/documents_cubit.dart';
import 'package:flutter_paperless_mobile/features/documents/model/document_filter.dart';
import 'package:flutter_paperless_mobile/features/documents/repository/document_repository.dart';
import 'package:flutter_paperless_mobile/features/labels/model/label.model.dart';
import 'package:flutter_paperless_mobile/features/labels/view/widgets/linked_documents_preview.dart';
class LabelItem<T extends Label> extends StatelessWidget {
final T label;
final String name;
final Widget content;
final void Function(T) onOpenEditPage;
final DocumentFilter Function(T) filterBuilder;
final Widget? leading;
const LabelItem({
super.key,
required this.name,
required this.content,
required this.onOpenEditPage,
required this.filterBuilder,
this.leading,
required this.label,
});
@override
Widget build(BuildContext context) {
return ListTile(
title: Text(name),
subtitle: content,
leading: leading,
onTap: () => onOpenEditPage(label),
trailing: _buildDocumentCountWidget(context),
);
}
Widget _buildDocumentCountWidget(BuildContext context) {
return TextButton.icon(
label: const Icon(Icons.link),
icon: Text(_formatDocumentCount(label.documentCount)),
onPressed: (label.documentCount ?? 0) == 0
? null
: () {
final filter = filterBuilder(label);
Navigator.push(
context,
MaterialPageRoute(
builder: (context) => LabelBlocProvider(
child: BlocProvider(
create: (context) =>
DocumentsCubit(getIt<DocumentRepository>())..updateFilter(filter: filter),
child: LinkedDocumentsPreview(filter: filter),
),
),
),
);
},
);
}
String _formatDocumentCount(int? count) {
if ((count ?? 0) > 99) {
return "99+";
}
return (count ?? 0).toString().padLeft(3);
}
}

View File

@@ -0,0 +1,73 @@
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:flutter_paperless_mobile/core/bloc/label_bloc_provider.dart';
import 'package:flutter_paperless_mobile/di_initializer.dart';
import 'package:flutter_paperless_mobile/features/documents/bloc/documents_cubit.dart';
import 'package:flutter_paperless_mobile/features/documents/model/document_filter.dart';
import 'package:flutter_paperless_mobile/features/documents/repository/document_repository.dart';
import 'package:flutter_paperless_mobile/features/labels/model/label.model.dart';
import 'package:flutter_paperless_mobile/features/labels/tags/model/tag.model.dart';
import 'package:flutter_paperless_mobile/features/labels/view/widgets/linked_documents_preview.dart';
class LabelListTile<T extends Label> extends StatelessWidget {
final T label;
final DocumentFilter Function(Label) filterBuilder;
final void Function() onOpenEditPage;
const LabelListTile(
this.label, {
super.key,
required this.filterBuilder,
required this.onOpenEditPage,
});
@override
Widget build(BuildContext context) {
return ListTile(
leading: (label is Tag)
? CircleAvatar(
backgroundColor: (label as Tag).color,
)
: null,
title: Text(label.name),
onTap: onOpenEditPage,
trailing: _buildDocumentCountWidget(context),
subtitle: Text(
(label.match?.isEmpty ?? true) ? "-" : label.match!,
maxLines: 2,
overflow: TextOverflow.ellipsis,
),
);
}
Widget _buildDocumentCountWidget(BuildContext context) {
return TextButton.icon(
label: const Icon(Icons.link),
icon: Text(_formatDocumentCount(label.documentCount)),
onPressed: (label.documentCount ?? 0) == 0
? null
: () {
final filter = filterBuilder(label);
Navigator.push(
context,
MaterialPageRoute(
builder: (context) => LabelBlocProvider(
child: BlocProvider(
create: (context) =>
DocumentsCubit(getIt<DocumentRepository>())..updateFilter(filter: filter),
child: LinkedDocumentsPreview(filter: filter),
),
),
),
);
},
);
}
String _formatDocumentCount(int? count) {
if ((count ?? 0) > 99) {
return "99+";
}
return (count ?? 0).toString().padLeft(3);
}
}

View File

@@ -0,0 +1,52 @@
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:flutter_paperless_mobile/core/bloc/label_cubit.dart';
import 'package:flutter_paperless_mobile/features/documents/model/document_filter.dart';
import 'package:flutter_paperless_mobile/features/labels/model/label.model.dart';
import 'package:flutter_paperless_mobile/features/labels/view/widgets/label_item.dart';
class LabelTabView<T extends Label> extends StatelessWidget {
final LabelCubit<T> cubit;
final DocumentFilter Function(Label) filterBuilder;
final void Function(T) onOpenEditPage;
/// Displayed as the subtitle of the [ListTile]
final Widget Function(T)? contentBuilder;
/// Displayed as the leading widget of the [ListTile]
final Widget Function(T)? leadingBuilder;
const LabelTabView({
super.key,
required this.cubit,
required this.filterBuilder,
this.contentBuilder,
this.leadingBuilder,
required this.onOpenEditPage,
});
@override
Widget build(BuildContext context) {
return BlocBuilder<Cubit<Map<int, T>>, Map<int, T>>(
bloc: cubit,
builder: (context, state) {
final labels = state.values.toList()..sort();
return RefreshIndicator(
onRefresh: cubit.initialize,
child: ListView(
children: labels
.map((l) => LabelItem<T>(
name: l.name,
content: contentBuilder?.call(l) ?? Text(l.match ?? '-'),
onOpenEditPage: onOpenEditPage,
filterBuilder: filterBuilder,
leading: leadingBuilder?.call(l),
label: l,
))
.toList(),
),
);
},
);
}
}

View File

@@ -0,0 +1,66 @@
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:flutter_paperless_mobile/core/bloc/label_bloc_provider.dart';
import 'package:flutter_paperless_mobile/features/documents/bloc/documents_cubit.dart';
import 'package:flutter_paperless_mobile/features/documents/bloc/documents_state.dart';
import 'package:flutter_paperless_mobile/features/documents/model/document.model.dart';
import 'package:flutter_paperless_mobile/features/documents/model/document_filter.dart';
import 'package:flutter_paperless_mobile/features/documents/view/pages/document_details_page.dart';
import 'package:flutter_paperless_mobile/features/documents/view/widgets/list/document_list.dart';
import 'package:flutter_paperless_mobile/generated/l10n.dart';
import 'package:infinite_scroll_pagination/infinite_scroll_pagination.dart';
class LinkedDocumentsPreview extends StatefulWidget {
final DocumentFilter filter;
const LinkedDocumentsPreview({super.key, required this.filter});
@override
State<LinkedDocumentsPreview> createState() => _LinkedDocumentsPreviewState();
}
class _LinkedDocumentsPreviewState extends State<LinkedDocumentsPreview> {
final PagingController<int, DocumentModel> _pagingController = PagingController(firstPageKey: 1);
@override
void initState() {
super.initState();
_pagingController.nextPageKey = null;
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: Text(S.of(context).linkedDocumentsPageTitle),
),
body: BlocBuilder<DocumentsCubit, DocumentsState>(
builder: (context, state) {
_pagingController.itemList = state.documents;
return CustomScrollView(
slivers: [
DocumentListView(
onTap: (doc) {
Navigator.push(
context,
MaterialPageRoute(
builder: (ctxt) => LabelBlocProvider(
child: BlocProvider.value(
value: BlocProvider.of<DocumentsCubit>(context),
child: DocumentDetailsPage(documentId: doc.id)),
),
),
);
},
pagingController: _pagingController,
state: state,
onSelected: BlocProvider.of<DocumentsCubit>(context).toggleDocumentSelection,
hasInternetConnection: true,
),
],
);
},
),
);
}
}