mirror of
https://github.com/Xevion/paperless-mobile.git
synced 2025-12-08 12:07:54 -06:00
Initial commit
This commit is contained in:
40
lib/features/app_intro/application_intro_slideshow.dart
Normal file
40
lib/features/app_intro/application_intro_slideshow.dart
Normal file
@@ -0,0 +1,40 @@
|
||||
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/extensions/flutter_extensions.dart';
|
||||
import 'package:flutter_paperless_mobile/features/app_intro/widgets/biometric_authentication_intro_slide.dart';
|
||||
import 'package:flutter_paperless_mobile/features/app_intro/widgets/configuration_done_intro_slide.dart';
|
||||
import 'package:flutter_paperless_mobile/features/app_intro/widgets/welcome_intro_slide.dart';
|
||||
import 'package:flutter_paperless_mobile/features/home/view/home_page.dart';
|
||||
import 'package:flutter_paperless_mobile/features/settings/bloc/application_settings_cubit.dart';
|
||||
import 'package:intro_slider/intro_slider.dart';
|
||||
|
||||
class ApplicationIntroSlideshow extends StatelessWidget {
|
||||
const ApplicationIntroSlideshow({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return WillPopScope(
|
||||
onWillPop: () async => false,
|
||||
child: IntroSlider(
|
||||
renderDoneBtn: TextButton(
|
||||
child: Text("GO"), //TODO: INTL
|
||||
onPressed: () {
|
||||
Navigator.pop(context);
|
||||
},
|
||||
),
|
||||
backgroundColorAllTabs: Theme.of(context).canvasColor,
|
||||
onDonePress: () => Navigator.of(context)
|
||||
.pushReplacement(MaterialPageRoute(builder: (context) => const HomePage())),
|
||||
listCustomTabs: [
|
||||
const WelcomeIntroSlide(),
|
||||
BlocProvider.value(
|
||||
value: getIt<ApplicationSettingsCubit>(),
|
||||
child: const BiometricAuthenticationIntroSlide(),
|
||||
),
|
||||
const ConfigurationDoneIntroSlide(),
|
||||
].padded(const EdgeInsets.all(16.0)),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
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/login/services/authentication.service.dart';
|
||||
import 'package:flutter_paperless_mobile/features/settings/bloc/application_settings_cubit.dart';
|
||||
import 'package:flutter_paperless_mobile/features/settings/model/application_settings_state.dart';
|
||||
import 'package:flutter_paperless_mobile/util.dart';
|
||||
|
||||
class BiometricAuthenticationIntroSlide extends StatefulWidget {
|
||||
const BiometricAuthenticationIntroSlide({
|
||||
Key? key,
|
||||
}) : super(key: key);
|
||||
|
||||
@override
|
||||
State<BiometricAuthenticationIntroSlide> createState() =>
|
||||
_BiometricAuthenticationIntroSlideState();
|
||||
}
|
||||
|
||||
class _BiometricAuthenticationIntroSlideState extends State<BiometricAuthenticationIntroSlide> {
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
//TODO: INTL
|
||||
return BlocBuilder<ApplicationSettingsCubit, ApplicationSettingsState>(
|
||||
builder: (context, settings) {
|
||||
return Column(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceAround,
|
||||
crossAxisAlignment: CrossAxisAlignment.center,
|
||||
children: [
|
||||
Text(
|
||||
"Configure Biometric Authentication",
|
||||
style: Theme.of(context).textTheme.titleLarge,
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
Text(
|
||||
"It is highly recommended to additionally secure your local data. Do you want to enable biometric authentication?",
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
Column(
|
||||
children: [
|
||||
const Icon(
|
||||
Icons.fingerprint,
|
||||
size: 48,
|
||||
),
|
||||
const SizedBox(
|
||||
height: 32,
|
||||
),
|
||||
Builder(builder: (context) {
|
||||
if (settings.isLocalAuthenticationEnabled) {
|
||||
return ElevatedButton.icon(
|
||||
icon: Icon(
|
||||
Icons.done,
|
||||
color: Colors.green,
|
||||
),
|
||||
label: Text("Enabled"),
|
||||
onPressed: null,
|
||||
);
|
||||
}
|
||||
return ElevatedButton(
|
||||
child: Text("Enable"),
|
||||
onPressed: () {
|
||||
final settings = BlocProvider.of<ApplicationSettingsCubit>(context).state;
|
||||
getIt<AuthenticationService>()
|
||||
.authenticateLocalUser("Please authenticate to secure Paperless Mobile")
|
||||
.then((isEnabled) {
|
||||
if (!isEnabled) {
|
||||
showSnackBar(context,
|
||||
"Could not set up biometric authentication. Please try again or skip for now.");
|
||||
return;
|
||||
}
|
||||
BlocProvider.of<ApplicationSettingsCubit>(context)
|
||||
.setIsBiometricAuthenticationEnabled(true);
|
||||
});
|
||||
},
|
||||
);
|
||||
}),
|
||||
],
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
class ConfigurationDoneIntroSlide extends StatelessWidget {
|
||||
const ConfigurationDoneIntroSlide({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Column(
|
||||
//TODO: INTL
|
||||
mainAxisAlignment: MainAxisAlignment.spaceAround,
|
||||
children: [
|
||||
Text(
|
||||
"All set up!",
|
||||
style: Theme.of(context).textTheme.titleLarge,
|
||||
),
|
||||
Icon(
|
||||
Icons.emoji_emotions_outlined,
|
||||
size: 64,
|
||||
),
|
||||
Text(
|
||||
"You've successfully configured Paperless Mobile! Press 'GO' to get started managing your documents.",
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
26
lib/features/app_intro/widgets/welcome_intro_slide.dart
Normal file
26
lib/features/app_intro/widgets/welcome_intro_slide.dart
Normal file
@@ -0,0 +1,26 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/src/widgets/framework.dart';
|
||||
|
||||
class WelcomeIntroSlide extends StatelessWidget {
|
||||
const WelcomeIntroSlide({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
//TODO: INTL
|
||||
return Column(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceAround,
|
||||
crossAxisAlignment: CrossAxisAlignment.center,
|
||||
children: [
|
||||
Text(
|
||||
"Welcome to Paperless Mobile!",
|
||||
style: Theme.of(context).textTheme.titleLarge,
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
Text(
|
||||
"Manage and add your documents on the go!",
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
130
lib/features/documents/bloc/documents_cubit.dart
Normal file
130
lib/features/documents/bloc/documents_cubit.dart
Normal file
@@ -0,0 +1,130 @@
|
||||
import 'dart:typed_data';
|
||||
|
||||
import 'package:flutter_bloc/flutter_bloc.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/model/paged_search_result.dart';
|
||||
import 'package:flutter_paperless_mobile/features/documents/repository/document_repository.dart';
|
||||
import 'package:injectable/injectable.dart';
|
||||
|
||||
@singleton
|
||||
class DocumentsCubit extends Cubit<DocumentsState> {
|
||||
final DocumentRepository documentRepository;
|
||||
|
||||
DocumentsCubit(this.documentRepository) : super(DocumentsState.initial);
|
||||
|
||||
Future<void> addDocument(
|
||||
Uint8List bytes,
|
||||
String fileName, {
|
||||
required String title,
|
||||
required void Function(DocumentModel document) onConsumptionFinished,
|
||||
int? documentType,
|
||||
int? correspondent,
|
||||
List<int>? tags,
|
||||
DateTime? createdAt,
|
||||
}) async {
|
||||
await documentRepository.create(
|
||||
bytes,
|
||||
fileName,
|
||||
title: title,
|
||||
documentType: documentType,
|
||||
correspondent: correspondent,
|
||||
tags: tags,
|
||||
createdAt: createdAt,
|
||||
);
|
||||
// documentRepository
|
||||
// .waitForConsumptionFinished(fileName, title)
|
||||
// .then((value) => onConsumptionFinished(value));
|
||||
}
|
||||
|
||||
Future<void> removeDocument(DocumentModel document) async {
|
||||
await documentRepository.delete(document);
|
||||
return await reloadDocuments();
|
||||
}
|
||||
|
||||
Future<void> bulkRemoveDocuments(List<DocumentModel> documents) async {
|
||||
await documentRepository.bulkDelete(documents);
|
||||
return await reloadDocuments();
|
||||
}
|
||||
|
||||
Future<void> updateDocument(DocumentModel document) async {
|
||||
await documentRepository.update(document);
|
||||
await reloadDocuments();
|
||||
}
|
||||
|
||||
Future<void> loadDocuments() async {
|
||||
final result = await documentRepository.find(state.filter);
|
||||
emit(DocumentsState(
|
||||
isLoaded: true,
|
||||
value: [...state.value, result],
|
||||
filter: state.filter,
|
||||
));
|
||||
}
|
||||
|
||||
Future<void> reloadDocuments() async {
|
||||
if (state.currentPageNumber >= 5) {
|
||||
return _bulkReloadDocuments();
|
||||
}
|
||||
var newPages = <PagedSearchResult>[];
|
||||
for (final page in state.value) {
|
||||
final result = await documentRepository.find(state.filter.copyWith(page: page.pageKey));
|
||||
newPages.add(result);
|
||||
}
|
||||
emit(DocumentsState(isLoaded: true, value: newPages, filter: state.filter));
|
||||
}
|
||||
|
||||
Future<void> _bulkReloadDocuments() async {
|
||||
final result = await documentRepository
|
||||
.find(state.filter.copyWith(page: 1, pageSize: state.documents.length));
|
||||
emit(DocumentsState(isLoaded: true, value: [result], filter: state.filter));
|
||||
}
|
||||
|
||||
Future<void> loadMore() async {
|
||||
if (state.isLastPageLoaded) {
|
||||
return;
|
||||
}
|
||||
final newFilter = state.filter.copyWith(page: state.filter.page + 1);
|
||||
final result = await documentRepository.find(newFilter);
|
||||
emit(DocumentsState(isLoaded: true, value: [...state.value, result], filter: newFilter));
|
||||
}
|
||||
|
||||
Future<void> assignAsn(DocumentModel document) async {
|
||||
if (document.archiveSerialNumber == null) {
|
||||
final int asn = await documentRepository.findNextAsn();
|
||||
updateDocument(document.copyWith(archiveSerialNumber: asn));
|
||||
}
|
||||
}
|
||||
|
||||
///
|
||||
/// Update filter state and automatically reload documents. Always resets page to 1.
|
||||
/// Use [DocumentsCubit.loadMore] to load more data.
|
||||
Future<void> updateFilter({
|
||||
DocumentFilter filter = DocumentFilter.initial,
|
||||
}) async {
|
||||
final result = await documentRepository.find(filter.copyWith(page: 1));
|
||||
emit(DocumentsState(filter: filter, value: [result], isLoaded: true));
|
||||
}
|
||||
|
||||
void toggleDocumentSelection(DocumentModel model) {
|
||||
if (state.selection.contains(model)) {
|
||||
emit(
|
||||
state.copyWith(
|
||||
selection: state.selection.where((element) => element.id != model.id).toList(),
|
||||
),
|
||||
);
|
||||
} else {
|
||||
emit(
|
||||
state.copyWith(selection: [...state.selection, model]),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
void resetSelection() {
|
||||
emit(state.copyWith(selection: []));
|
||||
}
|
||||
|
||||
void reset() {
|
||||
emit(DocumentsState.initial);
|
||||
}
|
||||
}
|
||||
82
lib/features/documents/bloc/documents_state.dart
Normal file
82
lib/features/documents/bloc/documents_state.dart
Normal file
@@ -0,0 +1,82 @@
|
||||
import 'package:equatable/equatable.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/model/paged_search_result.dart';
|
||||
|
||||
class DocumentsState extends Equatable {
|
||||
final bool isLoaded;
|
||||
final DocumentFilter filter;
|
||||
final List<PagedSearchResult> value;
|
||||
final List<DocumentModel> selection;
|
||||
|
||||
const DocumentsState({
|
||||
required this.isLoaded,
|
||||
required this.value,
|
||||
required this.filter,
|
||||
this.selection = const [],
|
||||
});
|
||||
|
||||
static const DocumentsState initial = DocumentsState(
|
||||
isLoaded: false,
|
||||
value: [],
|
||||
filter: DocumentFilter.initial,
|
||||
selection: [],
|
||||
);
|
||||
|
||||
int get currentPageNumber {
|
||||
return filter.page;
|
||||
}
|
||||
|
||||
int? get nextPageNumber {
|
||||
return isLastPageLoaded ? null : currentPageNumber + 1;
|
||||
}
|
||||
|
||||
int get count {
|
||||
if (value.isEmpty) {
|
||||
return 0;
|
||||
}
|
||||
return value.first.count;
|
||||
}
|
||||
|
||||
bool get isLastPageLoaded {
|
||||
if (!isLoaded) {
|
||||
return false;
|
||||
}
|
||||
if (value.isNotEmpty) {
|
||||
return value.last.next == null;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
int inferPageCount({required int pageSize}) {
|
||||
if (!isLoaded) {
|
||||
return 100000;
|
||||
}
|
||||
if (value.isEmpty) {
|
||||
return 0;
|
||||
}
|
||||
return value.first.inferPageCount(pageSize: pageSize);
|
||||
}
|
||||
|
||||
List<DocumentModel> get documents {
|
||||
return value.fold([], (previousValue, element) => [...previousValue, ...element.results]);
|
||||
}
|
||||
|
||||
DocumentsState copyWith({
|
||||
bool overwrite = false,
|
||||
bool? isLoaded,
|
||||
List<PagedSearchResult>? value,
|
||||
DocumentFilter? filter,
|
||||
List<DocumentModel>? selection,
|
||||
}) {
|
||||
return DocumentsState(
|
||||
isLoaded: isLoaded ?? this.isLoaded,
|
||||
value: value ?? this.value,
|
||||
filter: filter ?? this.filter,
|
||||
selection: selection ?? this.selection,
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
List<Object?> get props => [isLoaded, filter, value, selection];
|
||||
}
|
||||
50
lib/features/documents/bloc/saved_view_cubit.dart
Normal file
50
lib/features/documents/bloc/saved_view_cubit.dart
Normal file
@@ -0,0 +1,50 @@
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
import 'package:flutter_paperless_mobile/di_initializer.dart';
|
||||
import 'package:flutter_paperless_mobile/features/documents/bloc/saved_view_state.dart';
|
||||
import 'package:flutter_paperless_mobile/features/documents/model/saved_view.model.dart';
|
||||
import 'package:flutter_paperless_mobile/features/documents/repository/saved_views_repository.dart';
|
||||
import 'package:injectable/injectable.dart';
|
||||
|
||||
@singleton
|
||||
class SavedViewCubit extends Cubit<SavedViewState> {
|
||||
SavedViewCubit() : super(SavedViewState(value: {}));
|
||||
|
||||
void selectView(SavedView? view) {
|
||||
emit(SavedViewState(value: state.value, selectedSavedViewId: view?.id));
|
||||
}
|
||||
|
||||
Future<SavedView> add(SavedView view) async {
|
||||
final savedView = await getIt<SavedViewsRepository>().save(view);
|
||||
emit(
|
||||
SavedViewState(
|
||||
value: {...state.value, savedView.id!: savedView},
|
||||
selectedSavedViewId: state.selectedSavedViewId,
|
||||
),
|
||||
);
|
||||
return savedView;
|
||||
}
|
||||
|
||||
Future<int> remove(SavedView view) async {
|
||||
final id = await getIt<SavedViewsRepository>().delete(view);
|
||||
final newValue = {...state.value};
|
||||
newValue.removeWhere((key, value) => key == id);
|
||||
emit(
|
||||
SavedViewState(
|
||||
value: newValue,
|
||||
selectedSavedViewId:
|
||||
view.id == state.selectedSavedViewId ? null : state.selectedSavedViewId,
|
||||
),
|
||||
);
|
||||
return id;
|
||||
}
|
||||
|
||||
Future<void> initialize() async {
|
||||
final views = await getIt<SavedViewsRepository>().getAll();
|
||||
final values = {for (var element in views) element.id!: element};
|
||||
emit(SavedViewState(value: values));
|
||||
}
|
||||
|
||||
void resetSelection() {
|
||||
emit(SavedViewState(value: state.value));
|
||||
}
|
||||
}
|
||||
15
lib/features/documents/bloc/saved_view_state.dart
Normal file
15
lib/features/documents/bloc/saved_view_state.dart
Normal file
@@ -0,0 +1,15 @@
|
||||
import 'package:equatable/equatable.dart';
|
||||
import 'package:flutter_paperless_mobile/features/documents/model/saved_view.model.dart';
|
||||
|
||||
class SavedViewState with EquatableMixin {
|
||||
final Map<int, SavedView> value;
|
||||
final int? selectedSavedViewId;
|
||||
|
||||
SavedViewState({
|
||||
required this.value,
|
||||
this.selectedSavedViewId,
|
||||
});
|
||||
|
||||
@override
|
||||
List<Object?> get props => [value, selectedSavedViewId];
|
||||
}
|
||||
19
lib/features/documents/model/bulk_edit.model.dart
Normal file
19
lib/features/documents/model/bulk_edit.model.dart
Normal file
@@ -0,0 +1,19 @@
|
||||
import 'package:flutter_paperless_mobile/core/type/json.dart';
|
||||
|
||||
class BulkEditAction {
|
||||
final List<int> documents;
|
||||
final String _method;
|
||||
final Map<String, dynamic> parameters;
|
||||
|
||||
BulkEditAction.delete(this.documents)
|
||||
: _method = 'delete',
|
||||
parameters = {};
|
||||
|
||||
JSON toJson() {
|
||||
return {
|
||||
'documents': documents,
|
||||
'method': _method,
|
||||
'parameters': parameters,
|
||||
};
|
||||
}
|
||||
}
|
||||
148
lib/features/documents/model/document.model.dart
Normal file
148
lib/features/documents/model/document.model.dart
Normal file
@@ -0,0 +1,148 @@
|
||||
// ignore_for_file: non_constant_identifier_names
|
||||
|
||||
import 'package:equatable/equatable.dart';
|
||||
import 'package:flutter_paperless_mobile/core/type/json.dart';
|
||||
import 'package:flutter_paperless_mobile/features/documents/model/query_parameters/id_query_parameter.dart';
|
||||
import 'package:flutter_paperless_mobile/features/documents/model/query_parameters/ids_query_parameter.dart';
|
||||
|
||||
class DocumentModel extends Equatable {
|
||||
static const idKey = 'id';
|
||||
static const titleKey = "title";
|
||||
static const contentKey = "content";
|
||||
static const archivedFileNameKey = "archived_file_name";
|
||||
static const asnKey = "archive_serial_number";
|
||||
static const createdKey = "created";
|
||||
static const modifiedKey = "modified";
|
||||
static const addedKey = "added";
|
||||
static const correspondentKey = "correspondent";
|
||||
static const originalFileNameKey = 'original_file_name';
|
||||
static const documentTypeKey = "document_type";
|
||||
static const tagsKey = "tags";
|
||||
static const storagePathKey = "storage_path";
|
||||
|
||||
final int id;
|
||||
final String title;
|
||||
final String? content;
|
||||
final List<int> tags;
|
||||
final int? documentType;
|
||||
final int? correspondent;
|
||||
final int? storagePath;
|
||||
final DateTime created;
|
||||
final DateTime modified;
|
||||
final DateTime added;
|
||||
final int? archiveSerialNumber;
|
||||
final String originalFileName;
|
||||
final String? archivedFileName;
|
||||
|
||||
const DocumentModel({
|
||||
required this.id,
|
||||
required this.title,
|
||||
this.content,
|
||||
this.tags = const <int>[],
|
||||
required this.documentType,
|
||||
required this.correspondent,
|
||||
required this.created,
|
||||
required this.modified,
|
||||
required this.added,
|
||||
this.archiveSerialNumber,
|
||||
required this.originalFileName,
|
||||
this.archivedFileName,
|
||||
this.storagePath,
|
||||
});
|
||||
|
||||
DocumentModel.fromJson(JSON json)
|
||||
: id = json[idKey],
|
||||
title = json[titleKey],
|
||||
content = json[contentKey],
|
||||
created = DateTime.parse(json[createdKey]),
|
||||
modified = DateTime.parse(json[modifiedKey]),
|
||||
added = DateTime.parse(json[addedKey]),
|
||||
archiveSerialNumber = json[asnKey],
|
||||
originalFileName = json[originalFileNameKey],
|
||||
archivedFileName = json[archivedFileNameKey],
|
||||
tags = (json[tagsKey] as List<dynamic>).cast<int>(),
|
||||
correspondent = json[correspondentKey],
|
||||
documentType = json[documentTypeKey],
|
||||
storagePath = json[storagePathKey];
|
||||
|
||||
JSON toJson() {
|
||||
return {
|
||||
idKey: id,
|
||||
titleKey: title,
|
||||
asnKey: archiveSerialNumber,
|
||||
archivedFileNameKey: archivedFileName,
|
||||
contentKey: content,
|
||||
correspondentKey: correspondent,
|
||||
documentTypeKey: documentType,
|
||||
createdKey: created.toUtc().toIso8601String(),
|
||||
modifiedKey: modified.toUtc().toIso8601String(),
|
||||
addedKey: added.toUtc().toIso8601String(),
|
||||
originalFileNameKey: originalFileName,
|
||||
tagsKey: tags,
|
||||
storagePathKey: storagePath,
|
||||
};
|
||||
}
|
||||
|
||||
DocumentModel copyWith({
|
||||
String? title,
|
||||
String? content,
|
||||
IdsQueryParameter? tags,
|
||||
IdQueryParameter? documentType,
|
||||
IdQueryParameter? correspondent,
|
||||
IdQueryParameter? storagePath,
|
||||
DateTime? created,
|
||||
DateTime? modified,
|
||||
DateTime? added,
|
||||
int? archiveSerialNumber,
|
||||
String? originalFileName,
|
||||
String? archivedFileName,
|
||||
}) {
|
||||
return DocumentModel(
|
||||
id: id,
|
||||
title: title ?? this.title,
|
||||
content: content ?? this.content,
|
||||
documentType: fromQuery(documentType, this.documentType),
|
||||
correspondent: fromQuery(correspondent, this.correspondent),
|
||||
storagePath: fromQuery(storagePath, this.storagePath),
|
||||
tags: fromListQuery(tags, this.tags),
|
||||
created: created ?? this.created,
|
||||
modified: modified ?? this.modified,
|
||||
added: added ?? this.added,
|
||||
originalFileName: originalFileName ?? this.originalFileName,
|
||||
archiveSerialNumber: archiveSerialNumber ?? this.archiveSerialNumber,
|
||||
archivedFileName: archivedFileName ?? this.archivedFileName,
|
||||
);
|
||||
}
|
||||
|
||||
int? fromQuery(IdQueryParameter? query, int? previous) {
|
||||
if (query == null) {
|
||||
return previous;
|
||||
}
|
||||
return query.id;
|
||||
}
|
||||
|
||||
List<int> fromListQuery(IdsQueryParameter? query, List<int> previous) {
|
||||
if (query == null) {
|
||||
return previous;
|
||||
}
|
||||
return query.ids;
|
||||
}
|
||||
|
||||
@override
|
||||
List<Object?> get props => [
|
||||
id,
|
||||
title,
|
||||
content,
|
||||
tags,
|
||||
documentType,
|
||||
storagePath,
|
||||
correspondent,
|
||||
created,
|
||||
modified,
|
||||
added,
|
||||
archiveSerialNumber,
|
||||
originalFileName,
|
||||
archivedFileName,
|
||||
storagePath
|
||||
];
|
||||
}
|
||||
168
lib/features/documents/model/document_filter.dart
Normal file
168
lib/features/documents/model/document_filter.dart
Normal file
@@ -0,0 +1,168 @@
|
||||
import 'package:equatable/equatable.dart';
|
||||
import 'package:flutter_paperless_mobile/features/documents/model/query_parameters/asn_query.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/sort_field.dart';
|
||||
import 'package:flutter_paperless_mobile/features/documents/model/query_parameters/query_type.dart';
|
||||
import 'package:flutter_paperless_mobile/features/documents/model/query_parameters/sort_order.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/util.dart';
|
||||
|
||||
class DocumentFilter with EquatableMixin {
|
||||
static const DocumentFilter initial = DocumentFilter();
|
||||
|
||||
static const DocumentFilter latestDocument = DocumentFilter(
|
||||
sortField: SortField.added,
|
||||
sortOrder: SortOrder.descending,
|
||||
pageSize: 1,
|
||||
page: 1,
|
||||
);
|
||||
|
||||
final int pageSize;
|
||||
final int page;
|
||||
final DocumentTypeQuery documentType;
|
||||
final CorrespondentQuery correspondent;
|
||||
final StoragePathQuery storagePath;
|
||||
final AsnQuery asn;
|
||||
final TagsQuery tags;
|
||||
final SortField sortField;
|
||||
final SortOrder sortOrder;
|
||||
final DateTime? addedDateAfter;
|
||||
final DateTime? addedDateBefore;
|
||||
final DateTime? createdDateAfter;
|
||||
final DateTime? createdDateBefore;
|
||||
final QueryType queryType;
|
||||
final String? queryText;
|
||||
|
||||
const DocumentFilter({
|
||||
this.createdDateAfter,
|
||||
this.createdDateBefore,
|
||||
this.documentType = const DocumentTypeQuery.unset(),
|
||||
this.correspondent = const CorrespondentQuery.unset(),
|
||||
this.storagePath = const StoragePathQuery.unset(),
|
||||
this.asn = const AsnQuery.unset(),
|
||||
this.tags = const TagsQuery.unset(),
|
||||
this.sortField = SortField.created,
|
||||
this.sortOrder = SortOrder.descending,
|
||||
this.page = 1,
|
||||
this.pageSize = 25,
|
||||
this.addedDateAfter,
|
||||
this.addedDateBefore,
|
||||
this.queryType = QueryType.titleAndContent,
|
||||
this.queryText,
|
||||
});
|
||||
|
||||
String toQueryString() {
|
||||
final StringBuffer sb = StringBuffer("page=$page&page_size=$pageSize");
|
||||
sb.write(documentType.toQueryParameter());
|
||||
sb.write(correspondent.toQueryParameter());
|
||||
sb.write(tags.toQueryParameter());
|
||||
sb.write(storagePath.toQueryParameter());
|
||||
sb.write(asn.toQueryParameter());
|
||||
|
||||
if (queryText?.isNotEmpty ?? false) {
|
||||
sb.write("&${queryType.queryParam}=$queryText");
|
||||
}
|
||||
|
||||
sb.write("&ordering=${sortOrder.queryString}${sortField.queryString}");
|
||||
|
||||
if (addedDateAfter != null) {
|
||||
sb.write("&added__date__gt=${dateFormat.format(addedDateAfter!)}");
|
||||
}
|
||||
|
||||
if (addedDateBefore != null) {
|
||||
sb.write("&added__date__lt=${dateFormat.format(addedDateBefore!)}");
|
||||
}
|
||||
|
||||
if (createdDateAfter != null) {
|
||||
sb.write("&created__date__gt=${dateFormat.format(createdDateAfter!)}");
|
||||
}
|
||||
|
||||
if (createdDateBefore != null) {
|
||||
sb.write("&created__date__lt=${dateFormat.format(createdDateBefore!)}");
|
||||
}
|
||||
|
||||
return sb.toString();
|
||||
}
|
||||
|
||||
@override
|
||||
String toString() {
|
||||
return toQueryString();
|
||||
}
|
||||
|
||||
DocumentFilter copyWith({
|
||||
int? pageSize,
|
||||
int? page,
|
||||
bool? onlyNoDocumentType,
|
||||
DocumentTypeQuery? documentType,
|
||||
CorrespondentQuery? correspondent,
|
||||
StoragePathQuery? storagePath,
|
||||
TagsQuery? tags,
|
||||
SortField? sortField,
|
||||
SortOrder? sortOrder,
|
||||
DateTime? addedDateAfter,
|
||||
DateTime? addedDateBefore,
|
||||
DateTime? createdDateBefore,
|
||||
DateTime? createdDateAfter,
|
||||
QueryType? queryType,
|
||||
String? queryText,
|
||||
}) {
|
||||
return DocumentFilter(
|
||||
pageSize: pageSize ?? this.pageSize,
|
||||
page: page ?? this.page,
|
||||
documentType: documentType ?? this.documentType,
|
||||
correspondent: correspondent ?? this.correspondent,
|
||||
storagePath: storagePath ?? this.storagePath,
|
||||
tags: tags ?? this.tags,
|
||||
sortField: sortField ?? this.sortField,
|
||||
sortOrder: sortOrder ?? this.sortOrder,
|
||||
addedDateAfter: addedDateAfter ?? this.addedDateAfter,
|
||||
addedDateBefore: addedDateBefore ?? this.addedDateBefore,
|
||||
queryType: queryType ?? this.queryType,
|
||||
queryText: queryText ?? this.queryText,
|
||||
createdDateBefore: createdDateBefore ?? this.createdDateBefore,
|
||||
createdDateAfter: createdDateAfter ?? this.createdDateAfter,
|
||||
);
|
||||
}
|
||||
|
||||
String? get titleOnlyMatchString {
|
||||
if (queryType == QueryType.title) {
|
||||
return queryText?.isEmpty ?? true ? null : queryText;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
String? get titleAndContentMatchString {
|
||||
if (queryType == QueryType.titleAndContent) {
|
||||
return queryText?.isEmpty ?? true ? null : queryText;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
String? get extendedMatchString {
|
||||
if (queryType == QueryType.extended) {
|
||||
return queryText?.isEmpty ?? true ? null : queryText;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
@override
|
||||
List<Object?> get props => [
|
||||
pageSize,
|
||||
page,
|
||||
documentType,
|
||||
correspondent,
|
||||
storagePath,
|
||||
asn,
|
||||
tags,
|
||||
sortField,
|
||||
sortOrder,
|
||||
addedDateAfter,
|
||||
addedDateBefore,
|
||||
createdDateAfter,
|
||||
createdDateBefore,
|
||||
queryType,
|
||||
queryText,
|
||||
];
|
||||
}
|
||||
40
lib/features/documents/model/document_meta_data.model.dart
Normal file
40
lib/features/documents/model/document_meta_data.model.dart
Normal file
@@ -0,0 +1,40 @@
|
||||
class DocumentMetaData {
|
||||
String originalChecksum;
|
||||
int originalSize;
|
||||
String originalMimeType;
|
||||
String mediaFilename;
|
||||
bool hasArchiveVersion;
|
||||
String? archiveChecksum;
|
||||
int? archiveSize;
|
||||
|
||||
DocumentMetaData({
|
||||
required this.originalChecksum,
|
||||
required this.originalSize,
|
||||
required this.originalMimeType,
|
||||
required this.mediaFilename,
|
||||
required this.hasArchiveVersion,
|
||||
this.archiveChecksum,
|
||||
this.archiveSize,
|
||||
});
|
||||
|
||||
DocumentMetaData.fromJson(Map<String, dynamic> json)
|
||||
: originalChecksum = json['original_checksum'],
|
||||
originalSize = json['original_size'],
|
||||
originalMimeType = json['original_mime_type'],
|
||||
mediaFilename = json['media_filename'],
|
||||
hasArchiveVersion = json['has_archive_version'],
|
||||
archiveChecksum = json['archive_checksum'],
|
||||
archiveSize = json['archive_size'];
|
||||
|
||||
Map<String, dynamic> toJson() {
|
||||
final data = <String, dynamic>{};
|
||||
data['original_checksum'] = originalChecksum;
|
||||
data['original_size'] = originalSize;
|
||||
data['original_mime_type'] = originalMimeType;
|
||||
data['media_filename'] = mediaFilename;
|
||||
data['has_archive_version'] = hasArchiveVersion;
|
||||
data['archive_checksum'] = archiveChecksum;
|
||||
data['archive_size'] = archiveSize;
|
||||
return data;
|
||||
}
|
||||
}
|
||||
166
lib/features/documents/model/filter_rule.model.dart
Normal file
166
lib/features/documents/model/filter_rule.model.dart
Normal file
@@ -0,0 +1,166 @@
|
||||
import 'package:equatable/equatable.dart';
|
||||
import 'package:flutter_paperless_mobile/core/type/json.dart';
|
||||
import 'package:flutter_paperless_mobile/features/documents/model/document_filter.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/query_type.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/util.dart';
|
||||
|
||||
class FilterRule with EquatableMixin {
|
||||
static const int titleRule = 0;
|
||||
static const int asnRule = 2;
|
||||
static const int correspondentRule = 3;
|
||||
static const int documentTypeRule = 4;
|
||||
static const int tagRule = 6;
|
||||
static const int createdBeforeRule = 8;
|
||||
static const int createdAfterRule = 9;
|
||||
static const int addedBeforeRule = 13;
|
||||
static const int addedAfterRule = 14;
|
||||
static const int titleAndContentRule = 19;
|
||||
static const int extendedRule = 20;
|
||||
static const int storagePathRule = 25;
|
||||
// Currently unsupported view optiosn:
|
||||
static const int _content = 1;
|
||||
static const int _isInInbox = 5;
|
||||
static const int _hasAnyTag = 7;
|
||||
static const int _createdYearIs = 10;
|
||||
static const int _createdMonthIs = 11;
|
||||
static const int _createdDayIs = 12;
|
||||
static const int _modifiedBefore = 15;
|
||||
static const int _modifiedAfter = 16;
|
||||
static const int _doesNotHaveTag = 17;
|
||||
static const int _doesNotHaveAsn = 18;
|
||||
static const int _moreLikeThis = 21;
|
||||
static const int _hasTagsIn = 22;
|
||||
static const int _asnGreaterThan = 23;
|
||||
static const int _asnLessThan = 24;
|
||||
|
||||
final int ruleType;
|
||||
final String? value;
|
||||
|
||||
FilterRule(this.ruleType, this.value);
|
||||
|
||||
FilterRule.fromJson(JSON json)
|
||||
: ruleType = json['rule_type'],
|
||||
value = json['value'];
|
||||
|
||||
JSON toJson() {
|
||||
return {
|
||||
'rule_type': ruleType,
|
||||
'value': value,
|
||||
};
|
||||
}
|
||||
|
||||
DocumentFilter applyToFilter(final DocumentFilter filter) {
|
||||
//TODO: Check in profiling mode if this is inefficient enough to cause stutters...
|
||||
switch (ruleType) {
|
||||
case titleRule:
|
||||
return filter.copyWith(queryText: value, queryType: QueryType.title);
|
||||
case documentTypeRule:
|
||||
return filter.copyWith(
|
||||
documentType: value == null
|
||||
? const DocumentTypeQuery.notAssigned()
|
||||
: DocumentTypeQuery.fromId(int.parse(value!)),
|
||||
);
|
||||
case correspondentRule:
|
||||
return filter.copyWith(
|
||||
correspondent: value == null
|
||||
? const CorrespondentQuery.notAssigned()
|
||||
: CorrespondentQuery.fromId(int.parse(value!)),
|
||||
);
|
||||
case storagePathRule:
|
||||
return filter.copyWith(
|
||||
storagePath: value == null
|
||||
? const StoragePathQuery.notAssigned()
|
||||
: StoragePathQuery.fromId(int.parse(value!)),
|
||||
);
|
||||
case tagRule:
|
||||
return filter.copyWith(
|
||||
tags: value == null
|
||||
? const TagsQuery.notAssigned()
|
||||
: TagsQuery.fromIds([...filter.tags.ids, int.parse(value!)]),
|
||||
);
|
||||
case createdBeforeRule:
|
||||
return filter.copyWith(createdDateBefore: value == null ? null : DateTime.parse(value!));
|
||||
case createdAfterRule:
|
||||
return filter.copyWith(createdDateAfter: value == null ? null : DateTime.parse(value!));
|
||||
case addedBeforeRule:
|
||||
return filter.copyWith(addedDateBefore: value == null ? null : DateTime.parse(value!));
|
||||
case addedAfterRule:
|
||||
return filter.copyWith(addedDateAfter: value == null ? null : DateTime.parse(value!));
|
||||
case titleAndContentRule:
|
||||
return filter.copyWith(queryText: value, queryType: QueryType.titleAndContent);
|
||||
case extendedRule:
|
||||
return filter.copyWith(queryText: value, queryType: QueryType.extended);
|
||||
//TODO: Add currently unused rules
|
||||
default:
|
||||
return filter;
|
||||
}
|
||||
}
|
||||
|
||||
///
|
||||
/// Converts a [DocumentFilter] to a list of [FilterRule]s.
|
||||
///
|
||||
static List<FilterRule> fromFilter(final DocumentFilter filter) {
|
||||
List<FilterRule> filterRules = [];
|
||||
if (filter.correspondent.onlyNotAssigned) {
|
||||
filterRules.add(FilterRule(correspondentRule, null));
|
||||
}
|
||||
if (filter.correspondent.isSet) {
|
||||
filterRules.add(FilterRule(correspondentRule, filter.correspondent.id!.toString()));
|
||||
}
|
||||
if (filter.documentType.onlyNotAssigned) {
|
||||
filterRules.add(FilterRule(documentTypeRule, null));
|
||||
}
|
||||
if (filter.documentType.isSet) {
|
||||
filterRules.add(FilterRule(documentTypeRule, filter.documentType.id!.toString()));
|
||||
}
|
||||
if (filter.storagePath.onlyNotAssigned) {
|
||||
filterRules.add(FilterRule(storagePathRule, null));
|
||||
}
|
||||
if (filter.storagePath.isSet) {
|
||||
filterRules.add(FilterRule(storagePathRule, filter.storagePath.id!.toString()));
|
||||
}
|
||||
if (filter.tags.onlyNotAssigned) {
|
||||
filterRules.add(FilterRule(tagRule, null));
|
||||
}
|
||||
if (filter.tags.isSet) {
|
||||
filterRules.addAll(filter.tags.ids.map((id) => FilterRule(tagRule, id.toString())));
|
||||
}
|
||||
|
||||
if (filter.queryText != null) {
|
||||
switch (filter.queryType) {
|
||||
case QueryType.title:
|
||||
filterRules.add(FilterRule(titleRule, filter.queryText!));
|
||||
break;
|
||||
case QueryType.titleAndContent:
|
||||
filterRules.add(FilterRule(titleAndContentRule, filter.queryText!));
|
||||
break;
|
||||
case QueryType.extended:
|
||||
filterRules.add(FilterRule(extendedRule, filter.queryText!));
|
||||
break;
|
||||
case QueryType.asn:
|
||||
filterRules.add(FilterRule(asnRule, filter.queryText!));
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (filter.createdDateAfter != null) {
|
||||
filterRules.add(FilterRule(createdAfterRule, dateFormat.format(filter.createdDateAfter!)));
|
||||
}
|
||||
if (filter.createdDateBefore != null) {
|
||||
filterRules.add(FilterRule(createdBeforeRule, dateFormat.format(filter.createdDateBefore!)));
|
||||
}
|
||||
if (filter.addedDateAfter != null) {
|
||||
filterRules.add(FilterRule(addedAfterRule, dateFormat.format(filter.addedDateAfter!)));
|
||||
}
|
||||
if (filter.addedDateBefore != null) {
|
||||
filterRules.add(FilterRule(addedBeforeRule, dateFormat.format(filter.addedDateBefore!)));
|
||||
}
|
||||
return filterRules;
|
||||
}
|
||||
|
||||
@override
|
||||
List<Object?> get props => [ruleType, value];
|
||||
}
|
||||
84
lib/features/documents/model/paged_search_result.dart
Normal file
84
lib/features/documents/model/paged_search_result.dart
Normal file
@@ -0,0 +1,84 @@
|
||||
import 'package:equatable/equatable.dart';
|
||||
import 'package:flutter_paperless_mobile/core/type/json.dart';
|
||||
import 'package:flutter_paperless_mobile/features/documents/model/document.model.dart';
|
||||
|
||||
const pageRegex = r".*page=(\d+).*";
|
||||
|
||||
class PagedSearchResult<T> extends Equatable {
|
||||
/// Total number of available items
|
||||
final int count;
|
||||
|
||||
/// Link to next page
|
||||
final String? next;
|
||||
|
||||
/// Link to previous page
|
||||
final String? previous;
|
||||
|
||||
/// Actual items
|
||||
final List<T> results;
|
||||
|
||||
int get pageKey {
|
||||
if (next != null) {
|
||||
final matches = RegExp(pageRegex).allMatches(next!);
|
||||
final group = matches.first.group(1)!;
|
||||
final nextPageKey = int.parse(group);
|
||||
return nextPageKey - 1;
|
||||
}
|
||||
if (previous != null) {
|
||||
// This is only executed if it's the last page or there is no data.
|
||||
final matches = RegExp(pageRegex).allMatches(previous!);
|
||||
if (matches.isEmpty) {
|
||||
//In case there is a match but a page is not explicitly set, the page is 1 per default. Therefore, if the previous page is 1, this page is 1+1=2
|
||||
return 2;
|
||||
}
|
||||
final group = matches.first.group(1)!;
|
||||
final previousPageKey = int.parse(group);
|
||||
return previousPageKey + 1;
|
||||
}
|
||||
return 1;
|
||||
}
|
||||
|
||||
const PagedSearchResult({
|
||||
required this.count,
|
||||
required this.next,
|
||||
required this.previous,
|
||||
required this.results,
|
||||
});
|
||||
|
||||
factory PagedSearchResult.fromJson(Map<dynamic, dynamic> json, T Function(JSON) fromJson) {
|
||||
return PagedSearchResult(
|
||||
count: json['count'],
|
||||
next: json['next'],
|
||||
previous: json['previous'],
|
||||
results: List<JSON>.from(json['results']).map<T>(fromJson).toList(),
|
||||
);
|
||||
}
|
||||
|
||||
PagedSearchResult copyWith({
|
||||
int? count,
|
||||
String? next,
|
||||
String? previous,
|
||||
List<DocumentModel>? results,
|
||||
}) {
|
||||
return PagedSearchResult(
|
||||
count: count ?? this.count,
|
||||
next: next ?? this.next,
|
||||
previous: previous ?? this.previous,
|
||||
results: results ?? this.results,
|
||||
);
|
||||
}
|
||||
|
||||
///
|
||||
/// Returns the number of pages based on the given [pageSize]. The last page
|
||||
/// might not exhaust its capacity.
|
||||
///
|
||||
int inferPageCount({required int pageSize}) {
|
||||
if (pageSize == 0) {
|
||||
return 0;
|
||||
}
|
||||
return (count / pageSize).round() + 1;
|
||||
}
|
||||
|
||||
@override
|
||||
List<Object?> get props => [count, next, previous, results];
|
||||
}
|
||||
10
lib/features/documents/model/query_parameters/asn_query.dart
Normal file
10
lib/features/documents/model/query_parameters/asn_query.dart
Normal file
@@ -0,0 +1,10 @@
|
||||
import 'package:flutter_paperless_mobile/features/documents/model/query_parameters/id_query_parameter.dart';
|
||||
|
||||
class AsnQuery extends IdQueryParameter {
|
||||
const AsnQuery.fromId(super.id) : super.fromId();
|
||||
const AsnQuery.unset() : super.unset();
|
||||
const AsnQuery.notAssigned() : super.notAssigned();
|
||||
|
||||
@override
|
||||
String get queryParameterKey => 'archive_serial_number';
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
import 'package:flutter_paperless_mobile/features/documents/model/query_parameters/id_query_parameter.dart';
|
||||
|
||||
class CorrespondentQuery extends IdQueryParameter {
|
||||
const CorrespondentQuery.fromId(super.id) : super.fromId();
|
||||
const CorrespondentQuery.unset() : super.unset();
|
||||
const CorrespondentQuery.notAssigned() : super.notAssigned();
|
||||
|
||||
@override
|
||||
String get queryParameterKey => 'correspondent';
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
import 'package:flutter_paperless_mobile/features/documents/model/query_parameters/id_query_parameter.dart';
|
||||
|
||||
class DocumentTypeQuery extends IdQueryParameter {
|
||||
const DocumentTypeQuery.fromId(super.id) : super.fromId();
|
||||
const DocumentTypeQuery.unset() : super.unset();
|
||||
const DocumentTypeQuery.notAssigned() : super.notAssigned();
|
||||
|
||||
@override
|
||||
String get queryParameterKey => 'document_type';
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
import 'package:equatable/equatable.dart';
|
||||
import 'package:flutter/widgets.dart';
|
||||
|
||||
abstract class IdQueryParameter extends Equatable {
|
||||
final bool _onlyNotAssigned;
|
||||
final int? _id;
|
||||
|
||||
const IdQueryParameter.notAssigned()
|
||||
: _onlyNotAssigned = true,
|
||||
_id = null;
|
||||
|
||||
const IdQueryParameter.fromId(int? id)
|
||||
: _onlyNotAssigned = false,
|
||||
_id = id;
|
||||
|
||||
const IdQueryParameter.unset() : this.fromId(null);
|
||||
|
||||
bool get isUnset => _id == null && _onlyNotAssigned == false;
|
||||
|
||||
bool get isSet => _id != null && _onlyNotAssigned == false;
|
||||
|
||||
bool get onlyNotAssigned => _onlyNotAssigned;
|
||||
|
||||
int? get id => _id;
|
||||
|
||||
@protected
|
||||
String get queryParameterKey;
|
||||
|
||||
String toQueryParameter() {
|
||||
if (onlyNotAssigned) {
|
||||
return "&${queryParameterKey}__isnull=1";
|
||||
}
|
||||
|
||||
return isUnset ? "" : "&${queryParameterKey}__id=$id";
|
||||
}
|
||||
|
||||
@override
|
||||
List<Object?> get props => [_onlyNotAssigned, _id];
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
import 'package:equatable/equatable.dart';
|
||||
|
||||
abstract class IdsQueryParameter with EquatableMixin {
|
||||
final List<int> _ids;
|
||||
final bool onlyNotAssigned;
|
||||
|
||||
const IdsQueryParameter.fromIds(List<int> ids)
|
||||
: onlyNotAssigned = false,
|
||||
_ids = ids;
|
||||
|
||||
const IdsQueryParameter.notAssigned()
|
||||
: onlyNotAssigned = true,
|
||||
_ids = const [];
|
||||
|
||||
const IdsQueryParameter.unset()
|
||||
: onlyNotAssigned = false,
|
||||
_ids = const [];
|
||||
|
||||
bool get isUnset => _ids.isEmpty && onlyNotAssigned == false;
|
||||
|
||||
bool get isSet => _ids.isNotEmpty && onlyNotAssigned == false;
|
||||
|
||||
List<int> get ids => _ids;
|
||||
|
||||
String toQueryParameter();
|
||||
|
||||
@override
|
||||
List<Object?> get props => [onlyNotAssigned, _ids];
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
enum QueryType {
|
||||
title('title__icontains'),
|
||||
titleAndContent('title_content'),
|
||||
extended('query'),
|
||||
asn('asn');
|
||||
|
||||
final String queryParam;
|
||||
const QueryType(this.queryParam);
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
enum SortField {
|
||||
archiveSerialNumber("archive_serial_number"),
|
||||
correspondentName("correspondent__name"),
|
||||
title("title"),
|
||||
documentType("documentType"),
|
||||
created("created"),
|
||||
added("added"),
|
||||
modified("modified");
|
||||
|
||||
final String queryString;
|
||||
|
||||
const SortField(this.queryString);
|
||||
|
||||
@override
|
||||
String toString() {
|
||||
return name.toLowerCase();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
enum SortOrder {
|
||||
ascending(""),
|
||||
descending("-");
|
||||
|
||||
final String queryString;
|
||||
const SortOrder(this.queryString);
|
||||
|
||||
SortOrder toggle() {
|
||||
return this == ascending ? descending : ascending;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
import 'package:flutter_paperless_mobile/features/documents/model/query_parameters/id_query_parameter.dart';
|
||||
|
||||
class StoragePathQuery extends IdQueryParameter {
|
||||
const StoragePathQuery.fromId(super.id) : super.fromId();
|
||||
const StoragePathQuery.unset() : super.unset();
|
||||
const StoragePathQuery.notAssigned() : super.notAssigned();
|
||||
|
||||
@override
|
||||
String get queryParameterKey => 'storage_path';
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
import 'package:flutter_paperless_mobile/features/documents/model/query_parameters/ids_query_parameter.dart';
|
||||
|
||||
class TagsQuery extends IdsQueryParameter {
|
||||
const TagsQuery.fromIds(super.ids) : super.fromIds();
|
||||
const TagsQuery.unset() : super.unset();
|
||||
const TagsQuery.notAssigned() : super.notAssigned();
|
||||
|
||||
@override
|
||||
String toQueryParameter() {
|
||||
if (onlyNotAssigned) {
|
||||
return '&is_tagged=false';
|
||||
}
|
||||
return isUnset ? "" : '&tags__id__all=${ids.join(',')}';
|
||||
}
|
||||
}
|
||||
88
lib/features/documents/model/saved_view.model.dart
Normal file
88
lib/features/documents/model/saved_view.model.dart
Normal file
@@ -0,0 +1,88 @@
|
||||
import 'package:equatable/equatable.dart';
|
||||
import 'package:flutter_paperless_mobile/core/type/json.dart';
|
||||
import 'package:flutter_paperless_mobile/features/documents/model/document_filter.dart';
|
||||
import 'package:flutter_paperless_mobile/features/documents/model/filter_rule.model.dart';
|
||||
import 'package:flutter_paperless_mobile/features/documents/model/query_parameters/sort_field.dart';
|
||||
import 'package:flutter_paperless_mobile/features/documents/model/query_parameters/sort_order.dart';
|
||||
|
||||
class SavedView with EquatableMixin {
|
||||
final int? id;
|
||||
final String name;
|
||||
|
||||
final bool showOnDashboard;
|
||||
final bool showInSidebar;
|
||||
|
||||
final SortField sortField;
|
||||
final bool sortReverse;
|
||||
final List<FilterRule> filterRules;
|
||||
|
||||
SavedView({
|
||||
this.id,
|
||||
required this.name,
|
||||
required this.showOnDashboard,
|
||||
required this.showInSidebar,
|
||||
required this.sortField,
|
||||
required this.sortReverse,
|
||||
required this.filterRules,
|
||||
}) {
|
||||
filterRules.sort(
|
||||
(a, b) => (a.ruleType.compareTo(b.ruleType) != 0
|
||||
? a.ruleType.compareTo(b.ruleType)
|
||||
: a.value?.compareTo(b.value ?? "") ?? -1),
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
List<Object?> get props =>
|
||||
[name, showOnDashboard, showInSidebar, sortField, sortReverse, filterRules];
|
||||
|
||||
SavedView.fromJson(JSON json)
|
||||
: this(
|
||||
id: json['id'],
|
||||
name: json['name'],
|
||||
showOnDashboard: json['show_on_dashboard'],
|
||||
showInSidebar: json['show_in_sidebar'],
|
||||
sortField:
|
||||
SortField.values.where((order) => order.queryString == json['sort_field']).first,
|
||||
sortReverse: json['sort_reverse'],
|
||||
filterRules:
|
||||
json['filter_rules'].cast<JSON>().map<FilterRule>(FilterRule.fromJson).toList(),
|
||||
);
|
||||
|
||||
DocumentFilter toDocumentFilter() {
|
||||
return filterRules.fold(
|
||||
DocumentFilter(
|
||||
sortOrder: sortReverse ? SortOrder.ascending : SortOrder.descending,
|
||||
sortField: sortField,
|
||||
),
|
||||
(filter, filterRule) => filterRule.applyToFilter(filter),
|
||||
);
|
||||
}
|
||||
|
||||
SavedView.fromDocumentFilter(
|
||||
DocumentFilter filter, {
|
||||
required String name,
|
||||
required bool showInSidebar,
|
||||
required bool showOnDashboard,
|
||||
}) : this(
|
||||
id: null,
|
||||
name: name,
|
||||
filterRules: FilterRule.fromFilter(filter),
|
||||
sortField: filter.sortField,
|
||||
showInSidebar: showInSidebar,
|
||||
showOnDashboard: showOnDashboard,
|
||||
sortReverse: filter.sortOrder == SortOrder.ascending,
|
||||
);
|
||||
|
||||
JSON toJson() {
|
||||
return {
|
||||
'id': id,
|
||||
'name': name,
|
||||
'show_on_dashboard': showOnDashboard,
|
||||
'show_in_sidebar': showInSidebar,
|
||||
'sort_reverse': sortReverse,
|
||||
'sort_field': sortField.queryString,
|
||||
'filter_rules': filterRules.map((rule) => rule.toJson()).toList(),
|
||||
};
|
||||
}
|
||||
}
|
||||
59
lib/features/documents/model/similar_document.model.dart
Normal file
59
lib/features/documents/model/similar_document.model.dart
Normal file
@@ -0,0 +1,59 @@
|
||||
import 'package:flutter_paperless_mobile/core/type/json.dart';
|
||||
import 'package:flutter_paperless_mobile/features/documents/model/document.model.dart';
|
||||
|
||||
class SimilarDocumentModel extends DocumentModel {
|
||||
final SearchHit searchHit;
|
||||
|
||||
const SimilarDocumentModel({
|
||||
required super.id,
|
||||
required super.title,
|
||||
required super.documentType,
|
||||
required super.correspondent,
|
||||
required super.created,
|
||||
required super.modified,
|
||||
required super.added,
|
||||
required super.originalFileName,
|
||||
required this.searchHit,
|
||||
super.archiveSerialNumber,
|
||||
super.archivedFileName,
|
||||
super.content,
|
||||
super.storagePath,
|
||||
super.tags,
|
||||
});
|
||||
|
||||
@override
|
||||
JSON toJson() {
|
||||
final json = super.toJson();
|
||||
json['__search_hit__'] = searchHit.toJson();
|
||||
return json;
|
||||
}
|
||||
|
||||
SimilarDocumentModel.fromJson(JSON json)
|
||||
: searchHit = SearchHit.fromJson(json),
|
||||
super.fromJson(json);
|
||||
}
|
||||
|
||||
class SearchHit {
|
||||
final double? score;
|
||||
final String? highlights;
|
||||
final int? rank;
|
||||
|
||||
SearchHit({
|
||||
this.score,
|
||||
required this.highlights,
|
||||
required this.rank,
|
||||
});
|
||||
|
||||
JSON toJson() {
|
||||
return {
|
||||
'score': score,
|
||||
'highlights': highlights,
|
||||
'rank': rank,
|
||||
};
|
||||
}
|
||||
|
||||
SearchHit.fromJson(JSON json)
|
||||
: score = json['score'],
|
||||
highlights = json['highlights'],
|
||||
rank = json['rank'];
|
||||
}
|
||||
33
lib/features/documents/repository/document_repository.dart
Normal file
33
lib/features/documents/repository/document_repository.dart
Normal file
@@ -0,0 +1,33 @@
|
||||
import 'dart:typed_data';
|
||||
|
||||
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/model/document_meta_data.model.dart';
|
||||
import 'package:flutter_paperless_mobile/features/documents/model/saved_view.model.dart';
|
||||
import 'package:flutter_paperless_mobile/features/documents/model/paged_search_result.dart';
|
||||
import 'package:flutter_paperless_mobile/features/documents/model/similar_document.model.dart';
|
||||
|
||||
abstract class DocumentRepository {
|
||||
Future<void> create(
|
||||
Uint8List documentBytes,
|
||||
String filename, {
|
||||
required String title,
|
||||
int? documentType,
|
||||
int? correspondent,
|
||||
List<int>? tags,
|
||||
DateTime? createdAt,
|
||||
});
|
||||
Future<DocumentModel> update(DocumentModel doc);
|
||||
Future<int> findNextAsn();
|
||||
Future<PagedSearchResult> find(DocumentFilter filter);
|
||||
Future<List<SimilarDocumentModel>> findSimilar(int docId);
|
||||
Future<int> delete(DocumentModel doc);
|
||||
Future<DocumentMetaData> getMetaData(DocumentModel document);
|
||||
Future<List<int>> bulkDelete(List<DocumentModel> models);
|
||||
Future<Uint8List> getPreview(int docId);
|
||||
String getThumbnailUrl(int docId);
|
||||
Future<DocumentModel> waitForConsumptionFinished(String filename, String title);
|
||||
Future<Uint8List> download(DocumentModel document);
|
||||
|
||||
Future<List<String>> autocomplete(String query, [int limit = 10]);
|
||||
}
|
||||
275
lib/features/documents/repository/document_repository_impl.dart
Normal file
275
lib/features/documents/repository/document_repository_impl.dart
Normal file
@@ -0,0 +1,275 @@
|
||||
import 'dart:async';
|
||||
import 'dart:convert';
|
||||
import 'dart:io';
|
||||
import 'dart:math';
|
||||
import 'dart:typed_data';
|
||||
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter_paperless_mobile/core/model/error_message.dart';
|
||||
import 'package:flutter_paperless_mobile/core/store/local_vault.dart';
|
||||
import 'package:flutter_paperless_mobile/core/type/json.dart';
|
||||
import 'package:flutter_paperless_mobile/core/util.dart';
|
||||
import 'package:flutter_paperless_mobile/di_initializer.dart';
|
||||
import 'package:flutter_paperless_mobile/extensions/dart_extensions.dart';
|
||||
import 'package:flutter_paperless_mobile/features/documents/model/bulk_edit.model.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/model/document_meta_data.model.dart';
|
||||
import 'package:flutter_paperless_mobile/features/documents/model/paged_search_result.dart';
|
||||
import 'package:flutter_paperless_mobile/features/documents/model/query_parameters/sort_field.dart';
|
||||
import 'package:flutter_paperless_mobile/features/documents/model/query_parameters/sort_order.dart';
|
||||
import 'package:flutter_paperless_mobile/features/documents/model/similar_document.model.dart';
|
||||
import 'package:flutter_paperless_mobile/features/documents/repository/document_repository.dart';
|
||||
import 'package:flutter_paperless_mobile/util.dart';
|
||||
import 'package:http/http.dart';
|
||||
import 'package:http/src/boundary_characters.dart'; //TODO: remove once there is either a paperless API update or there is a better solution...
|
||||
import 'package:injectable/injectable.dart';
|
||||
|
||||
@Injectable(as: DocumentRepository)
|
||||
class DocumentRepositoryImpl implements DocumentRepository {
|
||||
////
|
||||
//final StatusService statusService;
|
||||
final LocalVault localStorage;
|
||||
final BaseClient httpClient;
|
||||
|
||||
DocumentRepositoryImpl(
|
||||
//this.statusService,
|
||||
this.localStorage,
|
||||
@Named("timeoutClient") this.httpClient,
|
||||
);
|
||||
@override
|
||||
Future<void> create(
|
||||
Uint8List documentBytes,
|
||||
String filename, {
|
||||
required String title,
|
||||
int? documentType,
|
||||
int? correspondent,
|
||||
List<int>? tags,
|
||||
DateTime? createdAt,
|
||||
}) async {
|
||||
final auth = await localStorage.loadAuthenticationInformation();
|
||||
|
||||
if (auth == null) {
|
||||
throw const ErrorMessage(ErrorCode.notAuthenticated);
|
||||
}
|
||||
|
||||
// The multipart request has to be generated from scratch as the http library does
|
||||
// not allow the same key (tags) to be added multiple times. However, this is what the
|
||||
// paperless api expects, i.e. one block for each tag.
|
||||
final request = await getIt<HttpClient>().postUrl(
|
||||
Uri.parse("${auth.serverUrl}/api/documents/post_document/"),
|
||||
);
|
||||
|
||||
final boundary = _boundaryString();
|
||||
|
||||
StringBuffer bodyBuffer = StringBuffer();
|
||||
|
||||
var fields = <String, String>{};
|
||||
|
||||
fields.tryPutIfAbsent('title', () => title);
|
||||
fields.tryPutIfAbsent('created', () => formatDateNullable(createdAt));
|
||||
fields.tryPutIfAbsent(
|
||||
'correspondent', () => correspondent == null ? null : json.encode(correspondent));
|
||||
fields.tryPutIfAbsent(
|
||||
'document_type', () => documentType == null ? null : json.encode(documentType));
|
||||
|
||||
for (final key in fields.keys) {
|
||||
bodyBuffer.write(_buildMultipartField(key, fields[key]!, boundary));
|
||||
}
|
||||
|
||||
for (final tag in tags ?? <int>[]) {
|
||||
bodyBuffer.write(_buildMultipartField('tags', tag.toString(), boundary));
|
||||
}
|
||||
|
||||
bodyBuffer.write("--$boundary"
|
||||
'\r\nContent-Disposition: form-data; name="document"; filename="$filename"'
|
||||
"\r\nContent-type: application/octet-stream"
|
||||
"\r\n\r\n");
|
||||
|
||||
final closing = "\r\n--" + boundary + "--\r\n";
|
||||
|
||||
// Set headers
|
||||
request.headers.set(HttpHeaders.contentTypeHeader, "multipart/form-data; boundary=" + boundary);
|
||||
request.headers.set(HttpHeaders.contentLengthHeader,
|
||||
"${bodyBuffer.length + closing.length + documentBytes.lengthInBytes}");
|
||||
request.headers.set(HttpHeaders.authorizationHeader, "Token ${auth.token}");
|
||||
|
||||
//Write fields to request
|
||||
request.write(bodyBuffer.toString());
|
||||
//Stream file
|
||||
await request.addStream(Stream.fromIterable(documentBytes.map((e) => [e])));
|
||||
// Write closing boundary to request
|
||||
request.write(closing);
|
||||
|
||||
final response = await request.close();
|
||||
|
||||
if (response.statusCode != 200) {
|
||||
throw ErrorMessage(ErrorCode.documentUploadFailed, httpStatusCode: response.statusCode);
|
||||
}
|
||||
}
|
||||
|
||||
String _buildMultipartField(String fieldName, String value, String boundary) {
|
||||
return '--$boundary'
|
||||
'\r\nContent-Disposition: form-data; name="$fieldName"'
|
||||
'\r\nContent-type: text/plain'
|
||||
'\r\n\r\n' +
|
||||
value +
|
||||
'\r\n';
|
||||
}
|
||||
|
||||
String _boundaryString() {
|
||||
Random _random = Random();
|
||||
var prefix = 'dart-http-boundary-';
|
||||
var list = List<int>.generate(70 - prefix.length,
|
||||
(index) => boundaryCharacters[_random.nextInt(boundaryCharacters.length)],
|
||||
growable: false);
|
||||
return '$prefix${String.fromCharCodes(list)}';
|
||||
}
|
||||
|
||||
@override
|
||||
Future<DocumentModel> update(DocumentModel doc) async {
|
||||
final response = await httpClient.put(Uri.parse("/api/documents/${doc.id}/"),
|
||||
body: json.encode(doc.toJson()),
|
||||
headers: {"Content-Type": "application/json"}).timeout(requestTimeout);
|
||||
if (response.statusCode == 200) {
|
||||
return DocumentModel.fromJson(jsonDecode(response.body));
|
||||
} else {
|
||||
throw const ErrorMessage(ErrorCode.documentUpdateFailed);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Future<PagedSearchResult<DocumentModel>> find(DocumentFilter filter) async {
|
||||
final filterParams = filter.toQueryString();
|
||||
final response = await httpClient.get(
|
||||
Uri.parse("/api/documents/?$filterParams"),
|
||||
);
|
||||
if (response.statusCode == 200) {
|
||||
final searchResult = PagedSearchResult.fromJson(
|
||||
jsonDecode(const Utf8Decoder().convert(response.body.codeUnits)),
|
||||
DocumentModel.fromJson,
|
||||
);
|
||||
return searchResult;
|
||||
} else {
|
||||
throw const ErrorMessage(ErrorCode.documentLoadFailed);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Future<int> delete(DocumentModel doc) async {
|
||||
final response = await httpClient.delete(Uri.parse("/api/documents/${doc.id}/"));
|
||||
|
||||
if (response.statusCode == 204) {
|
||||
return Future.value(doc.id);
|
||||
}
|
||||
throw const ErrorMessage(ErrorCode.documentDeleteFailed);
|
||||
}
|
||||
|
||||
@override
|
||||
String getThumbnailUrl(int documentId) {
|
||||
return "/api/documents/$documentId/thumb/";
|
||||
}
|
||||
|
||||
String getPreviewUrl(int documentId) {
|
||||
return "/api/documents/$documentId/preview/";
|
||||
}
|
||||
|
||||
@override
|
||||
Future<Uint8List> getPreview(int documentId) async {
|
||||
final response = await httpClient.get(Uri.parse(getPreviewUrl(documentId)));
|
||||
if (response.statusCode == 200) {
|
||||
return response.bodyBytes;
|
||||
}
|
||||
throw const ErrorMessage(ErrorCode.documentPreviewFailed);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<int> findNextAsn() async {
|
||||
const DocumentFilter asnQueryFilter = DocumentFilter(
|
||||
sortField: SortField.archiveSerialNumber,
|
||||
sortOrder: SortOrder.descending,
|
||||
page: 1,
|
||||
pageSize: 1,
|
||||
);
|
||||
try {
|
||||
final result = await find(asnQueryFilter);
|
||||
return result.results
|
||||
.map((e) => e.archiveSerialNumber)
|
||||
.firstWhere((asn) => asn != null, orElse: () => 0)! +
|
||||
1;
|
||||
} on ErrorMessage catch (_) {
|
||||
throw const ErrorMessage(ErrorCode.documentAsnQueryFailed);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Future<List<int>> bulkDelete(List<DocumentModel> documentModels) async {
|
||||
final List<int> ids = documentModels.map((e) => e.id).toList();
|
||||
final action = BulkEditAction.delete(ids);
|
||||
final response = await httpClient.post(
|
||||
Uri.parse("/api/documents/bulk_edit/"),
|
||||
body: json.encode(action.toJson()),
|
||||
headers: {'Content-Type': 'application/json'},
|
||||
);
|
||||
if (response.statusCode == 200) {
|
||||
return ids;
|
||||
} else {
|
||||
throw const ErrorMessage(ErrorCode.documentBulkDeleteFailed);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Future<DocumentModel> waitForConsumptionFinished(String fileName, String title) async {
|
||||
// Always wait 5 seconds, processing usually takes longer...
|
||||
//await Future.delayed(const Duration(seconds: 5));
|
||||
PagedSearchResult<DocumentModel> results = await find(DocumentFilter.latestDocument);
|
||||
|
||||
while ((results.results.isEmpty ||
|
||||
(results.results[0].originalFileName != fileName && results.results[0].title != title))) {
|
||||
//TODO: maybe implement more intelligent retry logic or find workaround for websocket authentication...
|
||||
await Future.delayed(const Duration(seconds: 2));
|
||||
results = await find(DocumentFilter.latestDocument);
|
||||
}
|
||||
try {
|
||||
return results.results.first;
|
||||
} on StateError {
|
||||
throw const ErrorMessage(ErrorCode.documentUploadFailed);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Future<Uint8List> download(DocumentModel document) async {
|
||||
//TODO: Check if this works...
|
||||
final response = await httpClient.get(Uri.parse("/api/documents/${document.id}/download/"));
|
||||
return response.bodyBytes;
|
||||
}
|
||||
|
||||
@override
|
||||
Future<DocumentMetaData> getMetaData(DocumentModel document) async {
|
||||
final response = await httpClient.get(Uri.parse("/api/documents/${document.id}/metadata/"));
|
||||
return DocumentMetaData.fromJson(jsonDecode(response.body));
|
||||
}
|
||||
|
||||
@override
|
||||
Future<List<String>> autocomplete(String query, [int limit = 10]) async {
|
||||
final response =
|
||||
await httpClient.get(Uri.parse("/api/search/autocomplete/?query=$query&limit=$limit}"));
|
||||
if (response.statusCode == 200) {
|
||||
return json.decode(response.body) as List<String>;
|
||||
}
|
||||
throw const ErrorMessage(ErrorCode.autocompleteQueryError);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<List<SimilarDocumentModel>> findSimilar(int docId) async {
|
||||
final response =
|
||||
await httpClient.get(Uri.parse("/api/documents/?more_like=$docId&pageSize=10"));
|
||||
if (response.statusCode == 200) {
|
||||
return PagedSearchResult<SimilarDocumentModel>.fromJson(
|
||||
json.decode(response.body),
|
||||
SimilarDocumentModel.fromJson,
|
||||
).results;
|
||||
}
|
||||
throw const ErrorMessage(ErrorCode.similarQueryError);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
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/documents/model/saved_view.model.dart';
|
||||
import 'package:http/http.dart';
|
||||
import 'package:injectable/injectable.dart';
|
||||
|
||||
abstract class SavedViewsRepository {
|
||||
Future<List<SavedView>> getAll();
|
||||
|
||||
Future<SavedView> save(SavedView view);
|
||||
Future<int> delete(SavedView view);
|
||||
}
|
||||
|
||||
@Injectable(as: SavedViewsRepository)
|
||||
class SavedViewRepositoryImpl implements SavedViewsRepository {
|
||||
final BaseClient httpClient;
|
||||
|
||||
SavedViewRepositoryImpl(@Named("timeoutClient") this.httpClient);
|
||||
|
||||
@override
|
||||
Future<List<SavedView>> getAll() {
|
||||
return getCollection(
|
||||
"/api/saved_views/",
|
||||
SavedView.fromJson,
|
||||
ErrorCode.loadSavedViewsError,
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<SavedView> save(SavedView view) async {
|
||||
final response = await httpClient.post(
|
||||
Uri.parse("/api/saved_views/"),
|
||||
body: jsonEncode(view.toJson()),
|
||||
headers: {'Content-Type': 'application/json'},
|
||||
);
|
||||
if (response.statusCode == 201) {
|
||||
return SavedView.fromJson(jsonDecode(response.body));
|
||||
}
|
||||
throw ErrorMessage(ErrorCode.createSavedViewError, httpStatusCode: response.statusCode);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<int> delete(SavedView view) async {
|
||||
final response = await httpClient.delete(Uri.parse("/api/saved_views/${view.id}/"));
|
||||
if (response.statusCode == 204) {
|
||||
return view.id!;
|
||||
}
|
||||
throw ErrorMessage(ErrorCode.deleteSavedViewError, httpStatusCode: response.statusCode);
|
||||
}
|
||||
}
|
||||
418
lib/features/documents/view/pages/document_details_page.dart
Normal file
418
lib/features/documents/view/pages/document_details_page.dart
Normal file
@@ -0,0 +1,418 @@
|
||||
import 'dart:io';
|
||||
import 'dart:math';
|
||||
|
||||
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/core/logic/error_code_localization_mapper.dart';
|
||||
import 'package:flutter_paperless_mobile/core/model/error_message.dart';
|
||||
import 'package:flutter_paperless_mobile/core/widgets/highlighted_text.dart';
|
||||
import 'package:flutter_paperless_mobile/di_initializer.dart';
|
||||
import 'package:flutter_paperless_mobile/extensions/flutter_extensions.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_meta_data.model.dart';
|
||||
import 'package:flutter_paperless_mobile/features/documents/repository/document_repository.dart';
|
||||
import 'package:flutter_paperless_mobile/features/documents/view/pages/document_edit_page.dart';
|
||||
import 'package:flutter_paperless_mobile/features/documents/view/pages/document_view.dart';
|
||||
import 'package:flutter_paperless_mobile/features/documents/view/widgets/delete_document_confirmation_dialog.dart';
|
||||
import 'package:flutter_paperless_mobile/features/documents/view/widgets/document_preview.dart';
|
||||
import 'package:flutter_paperless_mobile/features/labels/correspondent/view/widgets/correspondent_widget.dart';
|
||||
import 'package:flutter_paperless_mobile/features/labels/document_type/view/widgets/document_type_widget.dart';
|
||||
import 'package:flutter_paperless_mobile/features/labels/storage_path/view/widgets/storage_path_widget.dart';
|
||||
import 'package:flutter_paperless_mobile/features/labels/tags/view/widgets/tags_widget.dart';
|
||||
import 'package:flutter_paperless_mobile/generated/l10n.dart';
|
||||
import 'package:flutter_paperless_mobile/util.dart';
|
||||
import 'package:intl/intl.dart';
|
||||
import 'package:path_provider/path_provider.dart';
|
||||
|
||||
class DocumentDetailsPage extends StatefulWidget {
|
||||
final int documentId;
|
||||
const DocumentDetailsPage({
|
||||
Key? key,
|
||||
required this.documentId,
|
||||
}) : super(key: key);
|
||||
|
||||
@override
|
||||
State<DocumentDetailsPage> createState() => _DocumentDetailsPageState();
|
||||
}
|
||||
|
||||
class _DocumentDetailsPageState extends State<DocumentDetailsPage> {
|
||||
static final DateFormat _detailedDateFormat = DateFormat("MMM d, yyyy HH:mm:ss");
|
||||
|
||||
bool _isDownloadPending = false;
|
||||
bool _isAssignAsnPending = false;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return BlocBuilder<DocumentsCubit, DocumentsState>(
|
||||
// buildWhen required because rebuild would happen after delete causing error.
|
||||
buildWhen: (previous, current) {
|
||||
return current.documents.where((element) => element.id == widget.documentId).isNotEmpty;
|
||||
},
|
||||
builder: (context, state) {
|
||||
final document = state.documents.where((doc) => doc.id == widget.documentId).first;
|
||||
return SafeArea(
|
||||
bottom: true,
|
||||
child: DefaultTabController(
|
||||
length: 3,
|
||||
child: Scaffold(
|
||||
floatingActionButtonLocation: FloatingActionButtonLocation.endDocked,
|
||||
floatingActionButton: FloatingActionButton(
|
||||
child: const Icon(Icons.edit),
|
||||
onPressed: () => _onEdit(document),
|
||||
),
|
||||
bottomNavigationBar: BottomAppBar(
|
||||
child: Row(
|
||||
mainAxisAlignment: MainAxisAlignment.start,
|
||||
children: [
|
||||
IconButton(
|
||||
icon: const Icon(Icons.delete),
|
||||
onPressed: () => _onDelete(document),
|
||||
).padded(const EdgeInsets.symmetric(horizontal: 8.0)),
|
||||
IconButton(
|
||||
icon: const Icon(Icons.download),
|
||||
onPressed: null, //() => _onDownload(document), //TODO: FIX
|
||||
),
|
||||
IconButton(
|
||||
icon: const Icon(Icons.open_in_new),
|
||||
onPressed: () => _onOpen(document),
|
||||
).padded(const EdgeInsets.symmetric(horizontal: 8.0)),
|
||||
],
|
||||
),
|
||||
),
|
||||
body: NestedScrollView(
|
||||
headerSliverBuilder: (context, innerBoxIsScrolled) => [
|
||||
SliverAppBar(
|
||||
leading: IconButton(
|
||||
icon: const Icon(
|
||||
Icons.arrow_back,
|
||||
color: Colors
|
||||
.black, //TODO: check if there is a way to dynamically determine color...
|
||||
),
|
||||
onPressed: () => Navigator.pop(context),
|
||||
),
|
||||
floating: true,
|
||||
pinned: true,
|
||||
expandedHeight: 200.0,
|
||||
flexibleSpace: DocumentPreview(
|
||||
id: document.id,
|
||||
fit: BoxFit.cover,
|
||||
),
|
||||
bottom: ColoredTabBar(
|
||||
backgroundColor: Theme.of(context).colorScheme.primaryContainer,
|
||||
tabBar: TabBar(
|
||||
tabs: [
|
||||
Tab(
|
||||
child: Text(
|
||||
S.of(context).documentDetailsPageTabOverviewLabel,
|
||||
style: TextStyle(
|
||||
color: Theme.of(context).colorScheme.onPrimaryContainer),
|
||||
),
|
||||
),
|
||||
Tab(
|
||||
child: Text(
|
||||
S.of(context).documentDetailsPageTabContentLabel,
|
||||
style: TextStyle(
|
||||
color: Theme.of(context).colorScheme.onPrimaryContainer),
|
||||
),
|
||||
),
|
||||
Tab(
|
||||
child: Text(
|
||||
S.of(context).documentDetailsPageTabMetaDataLabel,
|
||||
style: TextStyle(
|
||||
color: Theme.of(context).colorScheme.onPrimaryContainer),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
body: TabBarView(
|
||||
children: [
|
||||
_buildDocumentOverview(document, state.filter.titleAndContentMatchString),
|
||||
_buildDocumentContentView(document, state.filter.titleAndContentMatchString),
|
||||
_buildDocumentMetaDataView(document),
|
||||
].padded(),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildDocumentMetaDataView(DocumentModel document) {
|
||||
return FutureBuilder<DocumentMetaData>(
|
||||
future: getIt<DocumentRepository>().getMetaData(document),
|
||||
builder: (context, snapshot) {
|
||||
if (!snapshot.hasData) {
|
||||
return const Center(child: CircularProgressIndicator());
|
||||
}
|
||||
final meta = snapshot.data!;
|
||||
return ListView(
|
||||
children: [
|
||||
_DetailsItem.text(_detailedDateFormat.format(document.modified),
|
||||
label: S.of(context).documentModifiedPropertyLabel, context: context),
|
||||
_separator(),
|
||||
_DetailsItem.text(_detailedDateFormat.format(document.added),
|
||||
label: S.of(context).documentAddedPropertyLabel, context: context),
|
||||
_separator(),
|
||||
_DetailsItem(
|
||||
label: S.of(context).documentArchiveSerialNumberPropertyLongLabel,
|
||||
content: document.archiveSerialNumber != null
|
||||
? Text(document.archiveSerialNumber.toString())
|
||||
: OutlinedButton(
|
||||
child: Text(S.of(context).documentDetailsPageAssignAsnButtonLabel),
|
||||
onPressed: () => BlocProvider.of<DocumentsCubit>(context).assignAsn(document),
|
||||
),
|
||||
),
|
||||
_separator(),
|
||||
_DetailsItem.text(
|
||||
meta.mediaFilename,
|
||||
context: context,
|
||||
label: S.of(context).documentMetaDataMediaFilenamePropertyLabel,
|
||||
),
|
||||
_separator(),
|
||||
_DetailsItem.text(
|
||||
meta.originalChecksum,
|
||||
context: context,
|
||||
label: S.of(context).documentMetaDataChecksumLabel,
|
||||
),
|
||||
_separator(),
|
||||
_DetailsItem.text(formatBytes(meta.originalSize, 2),
|
||||
label: S.of(context).documentMetaDataOriginalFileSizeLabel, context: context),
|
||||
_separator(),
|
||||
_DetailsItem.text(
|
||||
meta.originalMimeType,
|
||||
label: S.of(context).documentMetaDataOriginalMimeTypeLabel,
|
||||
context: context,
|
||||
),
|
||||
_separator(),
|
||||
],
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildDocumentContentView(DocumentModel document, String? match) {
|
||||
return SingleChildScrollView(
|
||||
child: _DetailsItem(
|
||||
content: HighlightedText(
|
||||
text: document.content ?? "",
|
||||
highlights: match == null ? [] : match.split(" "),
|
||||
style: Theme.of(context).textTheme.bodyText2,
|
||||
caseSensitive: false,
|
||||
),
|
||||
label: S.of(context).documentDetailsPageTabContentLabel,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildDocumentOverview(DocumentModel document, String? match) {
|
||||
return ListView(
|
||||
children: [
|
||||
_DetailsItem(
|
||||
content: HighlightedText(
|
||||
text: document.title,
|
||||
highlights: match?.split(" ") ?? <String>[],
|
||||
),
|
||||
label: S.of(context).documentTitlePropertyLabel,
|
||||
),
|
||||
_separator(),
|
||||
_DetailsItem.text(
|
||||
DateFormat.yMMMd(Localizations.localeOf(context).toLanguageTag())
|
||||
.format(document.created),
|
||||
context: context,
|
||||
label: S.of(context).documentCreatedPropertyLabel,
|
||||
),
|
||||
_separator(),
|
||||
_DetailsItem(
|
||||
content: DocumentTypeWidget(
|
||||
documentTypeId: document.documentType,
|
||||
afterSelected: () {
|
||||
Navigator.pop(context);
|
||||
},
|
||||
),
|
||||
label: S.of(context).documentDocumentTypePropertyLabel,
|
||||
),
|
||||
_separator(),
|
||||
_DetailsItem(
|
||||
label: S.of(context).documentCorrespondentPropertyLabel,
|
||||
content: CorrespondentWidget(
|
||||
correspondentId: document.correspondent,
|
||||
afterSelected: () {
|
||||
Navigator.pop(context);
|
||||
},
|
||||
),
|
||||
),
|
||||
_separator(),
|
||||
_DetailsItem(
|
||||
label: S.of(context).documentStoragePathPropertyLabel,
|
||||
content: StoragePathWidget(
|
||||
pathId: document.storagePath,
|
||||
afterSelected: () {
|
||||
Navigator.pop(context);
|
||||
},
|
||||
),
|
||||
),
|
||||
_separator(),
|
||||
_DetailsItem(
|
||||
label: S.of(context).documentTagsPropertyLabel,
|
||||
content: Padding(
|
||||
padding: const EdgeInsets.only(top: 8.0),
|
||||
child: TagsWidget(
|
||||
tagIds: document.tags,
|
||||
),
|
||||
),
|
||||
),
|
||||
// _separator(),
|
||||
// FutureBuilder<List<SimilarDocumentModel>>(
|
||||
// future: getIt<DocumentRepository>().findSimilar(document.id),
|
||||
// builder: (context, snapshot) {
|
||||
// if (!snapshot.hasData) {
|
||||
// return CircularProgressIndicator();
|
||||
// }
|
||||
// return ExpansionTile(
|
||||
// tilePadding: const EdgeInsets.symmetric(horizontal: 8.0),
|
||||
// title: Text(
|
||||
// S.of(context).documentDetailsPageSimilarDocumentsLabel,
|
||||
// style:
|
||||
// Theme.of(context).textTheme.headline5?.copyWith(fontWeight: FontWeight.bold),
|
||||
// ),
|
||||
// children: snapshot.data!
|
||||
// .map((e) => DocumentListItem(
|
||||
// document: e,
|
||||
// onTap: (doc) {},
|
||||
// isSelected: false,
|
||||
// isAtLeastOneSelected: false))
|
||||
// .toList(),
|
||||
// );
|
||||
// }),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Widget _separator() {
|
||||
return const SizedBox(height: 32.0);
|
||||
}
|
||||
|
||||
void _onEdit(DocumentModel document) {
|
||||
Navigator.push(
|
||||
context,
|
||||
MaterialPageRoute(
|
||||
builder: (_) => LabelBlocProvider(
|
||||
child: DocumentEditPage(document: document),
|
||||
),
|
||||
maintainState: true,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _onDownload(DocumentModel document) async {
|
||||
setState(() {
|
||||
_isDownloadPending = true;
|
||||
});
|
||||
getIt<DocumentRepository>().download(document).then((bytes) async {
|
||||
//FIXME: logic currently flawed, some error somewhere but cannot look into directory...
|
||||
final dir = await getApplicationDocumentsDirectory();
|
||||
final dirPath = dir.path + "/files/";
|
||||
var filePath = dirPath + document.originalFileName;
|
||||
|
||||
if (File(filePath).existsSync()) {
|
||||
final count = dir
|
||||
.listSync()
|
||||
.where((entity) => (entity.path.contains(document.originalFileName)))
|
||||
.fold<int>(0, (previous, element) => previous + 1);
|
||||
final extSeperationIdx = filePath.lastIndexOf(".");
|
||||
filePath =
|
||||
filePath.replaceRange(extSeperationIdx, extSeperationIdx + 1, " (${count + 1}).");
|
||||
}
|
||||
Directory(dirPath).createSync();
|
||||
await File(filePath).writeAsBytes(bytes);
|
||||
_isDownloadPending = false;
|
||||
showSnackBar(context, "Document successfully downloaded to $filePath"); //TODO: INTL
|
||||
});
|
||||
}
|
||||
|
||||
Future<void> _onDelete(DocumentModel document) async {
|
||||
showDialog(
|
||||
context: context,
|
||||
builder: (context) => DeleteDocumentConfirmationDialog(document: document)).then((delete) {
|
||||
if (delete ?? false) {
|
||||
BlocProvider.of<DocumentsCubit>(context).removeDocument(document).then((value) {
|
||||
Navigator.pop(context);
|
||||
showSnackBar(context, S.of(context).documentDeleteSuccessMessage);
|
||||
}).onError<ErrorMessage>((error, _) {
|
||||
showSnackBar(context, translateError(context, error.code));
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
Future<void> _onOpen(DocumentModel document) async {
|
||||
Navigator.of(context).push(
|
||||
MaterialPageRoute(
|
||||
builder: (context) => DocumentView(document: document),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
static String formatBytes(int bytes, int decimals) {
|
||||
if (bytes <= 0) return "0 B";
|
||||
const suffixes = ["B", "KB", "MB", "GB", "TB", "PB", "EB", "ZB", "YB"];
|
||||
var i = (log(bytes) / log(1024)).floor();
|
||||
return ((bytes / pow(1024, i)).toStringAsFixed(decimals)) + ' ' + suffixes[i];
|
||||
}
|
||||
}
|
||||
|
||||
class _DetailsItem extends StatelessWidget {
|
||||
final String label;
|
||||
final Widget content;
|
||||
const _DetailsItem({Key? key, required this.label, required this.content}) : super(key: key);
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8.0),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
label,
|
||||
style: Theme.of(context).textTheme.headline5?.copyWith(fontWeight: FontWeight.bold),
|
||||
),
|
||||
content,
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
_DetailsItem.text(
|
||||
String text, {
|
||||
required this.label,
|
||||
required BuildContext context,
|
||||
}) : content = Text(text, style: Theme.of(context).textTheme.bodyText2);
|
||||
}
|
||||
|
||||
class ColoredTabBar extends Container implements PreferredSizeWidget {
|
||||
ColoredTabBar({
|
||||
super.key,
|
||||
required this.backgroundColor,
|
||||
required this.tabBar,
|
||||
});
|
||||
|
||||
final TabBar tabBar;
|
||||
final Color backgroundColor;
|
||||
@override
|
||||
Size get preferredSize => tabBar.preferredSize;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) => Container(
|
||||
color: backgroundColor,
|
||||
child: tabBar,
|
||||
);
|
||||
}
|
||||
204
lib/features/documents/view/pages/document_edit_page.dart
Normal file
204
lib/features/documents/view/pages/document_edit_page.dart
Normal file
@@ -0,0 +1,204 @@
|
||||
import 'dart:typed_data';
|
||||
|
||||
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/di_initializer.dart';
|
||||
import 'package:flutter_paperless_mobile/extensions/flutter_extensions.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/id_query_parameter.dart';
|
||||
import 'package:flutter_paperless_mobile/features/documents/model/query_parameters/ids_query_parameter.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/document_type/bloc/document_type_cubit.dart';
|
||||
import 'package:flutter_paperless_mobile/features/documents/bloc/documents_cubit.dart';
|
||||
import 'package:flutter_paperless_mobile/features/documents/model/document.model.dart';
|
||||
import 'package:flutter_paperless_mobile/features/documents/repository/document_repository.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/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/tags/view/widgets/tags_form_field.dart';
|
||||
import 'package:flutter_paperless_mobile/features/labels/view/widgets/label_form_field.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';
|
||||
import 'package:image/image.dart';
|
||||
import 'package:intl/intl.dart';
|
||||
|
||||
class DocumentEditPage extends StatefulWidget {
|
||||
final DocumentModel document;
|
||||
const DocumentEditPage({Key? key, required this.document}) : super(key: key);
|
||||
|
||||
@override
|
||||
State<DocumentEditPage> createState() => _DocumentEditPageState();
|
||||
}
|
||||
|
||||
class _DocumentEditPageState extends State<DocumentEditPage> {
|
||||
static const fkTitle = "title";
|
||||
static const fkCorrespondent = "correspondent";
|
||||
static const fkTags = "tags";
|
||||
static const fkDocumentType = "documentType";
|
||||
static const fkCreatedDate = "createdAtDate";
|
||||
static const fkStoragePath = 'storagePath';
|
||||
|
||||
late Future<Uint8List> documentBytes;
|
||||
|
||||
final GlobalKey<FormBuilderState> _formKey = GlobalKey();
|
||||
bool _isSubmitLoading = false;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
documentBytes = getIt<DocumentRepository>().getPreview(widget.document.id);
|
||||
|
||||
super.initState();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Scaffold(
|
||||
resizeToAvoidBottomInset: false,
|
||||
floatingActionButton: FloatingActionButton.extended(
|
||||
onPressed: () async {
|
||||
if (_formKey.currentState?.saveAndValidate() ?? false) {
|
||||
final values = _formKey.currentState!.value;
|
||||
final updatedDocument = widget.document.copyWith(
|
||||
title: values[fkTitle],
|
||||
created: values[fkCreatedDate],
|
||||
documentType: values[fkDocumentType] as IdQueryParameter,
|
||||
correspondent: values[fkCorrespondent] as IdQueryParameter,
|
||||
storagePath: values[fkStoragePath] as IdQueryParameter,
|
||||
tags: values[fkTags] as IdsQueryParameter,
|
||||
);
|
||||
setState(() {
|
||||
_isSubmitLoading = true;
|
||||
});
|
||||
await getIt<DocumentsCubit>().updateDocument(updatedDocument);
|
||||
Navigator.pop(context);
|
||||
showSnackBar(context, "Document successfully updated."); //TODO: INTL
|
||||
}
|
||||
},
|
||||
icon: const Icon(Icons.save),
|
||||
label: Text(S.of(context).genericActionSaveLabel),
|
||||
),
|
||||
appBar: AppBar(
|
||||
title: Text(S.of(context).documentEditPageTitle),
|
||||
bottom: _isSubmitLoading
|
||||
? const PreferredSize(
|
||||
preferredSize: Size.fromHeight(4),
|
||||
child: LinearProgressIndicator(),
|
||||
)
|
||||
: null,
|
||||
),
|
||||
extendBody: true,
|
||||
body: Padding(
|
||||
padding: EdgeInsets.only(
|
||||
bottom: MediaQuery.of(context).viewInsets.bottom,
|
||||
top: 8,
|
||||
left: 8,
|
||||
right: 8,
|
||||
),
|
||||
child: FormBuilder(
|
||||
key: _formKey,
|
||||
child: ListView(children: [
|
||||
_buildTitleFormField().padded(),
|
||||
_buildCreatedAtFormField().padded(),
|
||||
BlocBuilder<DocumentTypeCubit, Map<int, DocumentType>>(
|
||||
builder: (context, state) {
|
||||
return LabelFormField<DocumentType, DocumentTypeQuery>(
|
||||
notAssignedSelectable: false,
|
||||
formBuilderState: _formKey.currentState,
|
||||
labelCreationWidgetBuilder: (currentInput) => BlocProvider.value(
|
||||
value: BlocProvider.of<DocumentTypeCubit>(context),
|
||||
child: AddDocumentTypePage(
|
||||
initialName: currentInput,
|
||||
),
|
||||
),
|
||||
label: S.of(context).documentDocumentTypePropertyLabel,
|
||||
initialValue: DocumentTypeQuery.fromId(widget.document.documentType),
|
||||
state: state,
|
||||
name: fkDocumentType,
|
||||
queryParameterIdBuilder: DocumentTypeQuery.fromId,
|
||||
queryParameterNotAssignedBuilder: DocumentTypeQuery.notAssigned,
|
||||
prefixIcon: const Icon(Icons.description_outlined),
|
||||
);
|
||||
},
|
||||
).padded(),
|
||||
BlocBuilder<CorrespondentCubit, Map<int, Correspondent>>(
|
||||
builder: (context, state) {
|
||||
return LabelFormField<Correspondent, CorrespondentQuery>(
|
||||
notAssignedSelectable: false,
|
||||
formBuilderState: _formKey.currentState,
|
||||
labelCreationWidgetBuilder: (initialValue) => BlocProvider.value(
|
||||
value: BlocProvider.of<CorrespondentCubit>(context),
|
||||
child: AddCorrespondentPage(initalValue: initialValue),
|
||||
),
|
||||
label: S.of(context).documentCorrespondentPropertyLabel,
|
||||
state: state,
|
||||
initialValue: CorrespondentQuery.fromId(widget.document.correspondent),
|
||||
name: fkCorrespondent,
|
||||
queryParameterIdBuilder: CorrespondentQuery.fromId,
|
||||
queryParameterNotAssignedBuilder: CorrespondentQuery.notAssigned,
|
||||
prefixIcon: const Icon(Icons.person_outlined),
|
||||
);
|
||||
},
|
||||
).padded(),
|
||||
BlocBuilder<StoragePathCubit, Map<int, StoragePath>>(
|
||||
builder: (context, state) {
|
||||
return LabelFormField<StoragePath, StoragePathQuery>(
|
||||
notAssignedSelectable: false,
|
||||
formBuilderState: _formKey.currentState,
|
||||
labelCreationWidgetBuilder: (initialValue) => BlocProvider.value(
|
||||
value: BlocProvider.of<StoragePathCubit>(context),
|
||||
child: AddStoragePathPage(initalValue: initialValue),
|
||||
),
|
||||
label: S.of(context).documentStoragePathPropertyLabel,
|
||||
state: state,
|
||||
initialValue: StoragePathQuery.fromId(widget.document.storagePath),
|
||||
name: fkStoragePath,
|
||||
queryParameterIdBuilder: StoragePathQuery.fromId,
|
||||
queryParameterNotAssignedBuilder: StoragePathQuery.notAssigned,
|
||||
prefixIcon: const Icon(Icons.folder_outlined),
|
||||
);
|
||||
},
|
||||
).padded(),
|
||||
TagFormField(
|
||||
initialValue: TagsQuery.fromIds(widget.document.tags),
|
||||
name: fkTags,
|
||||
).padded(),
|
||||
]),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildTitleFormField() {
|
||||
return FormBuilderTextField(
|
||||
name: fkTitle,
|
||||
validator: FormBuilderValidators.required(),
|
||||
decoration: InputDecoration(
|
||||
label: Text(S.of(context).documentTitlePropertyLabel),
|
||||
),
|
||||
initialValue: widget.document.title,
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildCreatedAtFormField() {
|
||||
return FormBuilderDateTimePicker(
|
||||
inputType: InputType.date,
|
||||
name: fkCreatedDate,
|
||||
decoration: InputDecoration(
|
||||
prefixIcon: const Icon(Icons.calendar_month_outlined),
|
||||
label: Text(S.of(context).documentCreatedPropertyLabel),
|
||||
),
|
||||
initialValue: widget.document.created,
|
||||
format: DateFormat("dd. MMMM yyyy"), //TODO: Localized date format
|
||||
initialEntryMode: DatePickerEntryMode.calendar,
|
||||
);
|
||||
}
|
||||
}
|
||||
50
lib/features/documents/view/pages/document_view.dart
Normal file
50
lib/features/documents/view/pages/document_view.dart
Normal file
@@ -0,0 +1,50 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_paperless_mobile/di_initializer.dart';
|
||||
import 'package:flutter_paperless_mobile/features/documents/model/document.model.dart';
|
||||
import 'package:flutter_paperless_mobile/features/documents/repository/document_repository.dart';
|
||||
import 'package:flutter_paperless_mobile/generated/l10n.dart';
|
||||
import 'package:pdfx/pdfx.dart';
|
||||
|
||||
class DocumentView extends StatefulWidget {
|
||||
final DocumentModel document;
|
||||
|
||||
const DocumentView({
|
||||
Key? key,
|
||||
required this.document,
|
||||
}) : super(key: key);
|
||||
|
||||
@override
|
||||
State<DocumentView> createState() => _DocumentViewState();
|
||||
}
|
||||
|
||||
class _DocumentViewState extends State<DocumentView> {
|
||||
late PdfController _pdfController;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_pdfController = PdfController(
|
||||
document: PdfDocument.openData(
|
||||
getIt<DocumentRepository>().getPreview(widget.document.id),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Scaffold(
|
||||
appBar: AppBar(
|
||||
title: Text(S.of(context).documentPreviewPageTitle),
|
||||
),
|
||||
body: PdfView(
|
||||
builders: PdfViewBuilders<DefaultBuilderOptions>(
|
||||
options: const DefaultBuilderOptions(),
|
||||
pageLoaderBuilder: (context) => const Center(
|
||||
child: CircularProgressIndicator(),
|
||||
),
|
||||
),
|
||||
controller: _pdfController,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
239
lib/features/documents/view/pages/documents_page.dart
Normal file
239
lib/features/documents/view/pages/documents_page.dart
Normal file
@@ -0,0 +1,239 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
import 'package:flutter_paperless_mobile/core/bloc/connectivity_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/widgets/offline_banner.dart';
|
||||
import 'package:flutter_paperless_mobile/di_initializer.dart';
|
||||
import 'package:flutter_paperless_mobile/features/labels/correspondent/bloc/correspondents_cubit.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/documents/bloc/documents_state.dart';
|
||||
import 'package:flutter_paperless_mobile/features/documents/model/document.model.dart';
|
||||
import 'package:flutter_paperless_mobile/features/documents/view/pages/document_details_page.dart';
|
||||
import 'package:flutter_paperless_mobile/features/documents/view/widgets/documents_empty_state.dart';
|
||||
import 'package:flutter_paperless_mobile/features/documents/view/widgets/grid/document_grid.dart';
|
||||
import 'package:flutter_paperless_mobile/features/documents/view/widgets/list/document_list.dart';
|
||||
import 'package:flutter_paperless_mobile/features/documents/view/widgets/search/document_filter_panel.dart';
|
||||
import 'package:flutter_paperless_mobile/features/documents/view/widgets/selection/documents_page_app_bar.dart';
|
||||
import 'package:flutter_paperless_mobile/features/documents/view/widgets/sort_documents_button.dart';
|
||||
import 'package:flutter_paperless_mobile/features/home/view/widget/info_drawer.dart';
|
||||
import 'package:flutter_paperless_mobile/features/labels/storage_path/bloc/storage_path_cubit.dart';
|
||||
import 'package:flutter_paperless_mobile/features/login/bloc/authentication_cubit.dart';
|
||||
import 'package:flutter_paperless_mobile/features/labels/tags/bloc/tags_cubit.dart';
|
||||
import 'package:flutter_paperless_mobile/util.dart';
|
||||
import 'package:infinite_scroll_pagination/infinite_scroll_pagination.dart';
|
||||
import 'package:sliding_up_panel/sliding_up_panel.dart';
|
||||
|
||||
class DocumentsPage extends StatefulWidget {
|
||||
const DocumentsPage({Key? key}) : super(key: key);
|
||||
|
||||
@override
|
||||
State<DocumentsPage> createState() => _DocumentsPageState();
|
||||
}
|
||||
|
||||
class _DocumentsPageState extends State<DocumentsPage> {
|
||||
final PagingController<int, DocumentModel> _pagingController =
|
||||
PagingController<int, DocumentModel>(
|
||||
firstPageKey: 1,
|
||||
);
|
||||
|
||||
final PanelController _panelController = PanelController();
|
||||
ViewType _viewType = ViewType.list;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
final documentsCubit = BlocProvider.of<DocumentsCubit>(context);
|
||||
if (!documentsCubit.state.isLoaded) {
|
||||
documentsCubit.loadDocuments().onError<ErrorMessage>(
|
||||
(error, stackTrace) => showSnackBar(
|
||||
context,
|
||||
translateError(context, error.code),
|
||||
),
|
||||
);
|
||||
}
|
||||
_pagingController.addPageRequestListener(_loadNewPage);
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_pagingController.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
Future<void> _loadNewPage(int pageKey) async {
|
||||
final documentsCubit = BlocProvider.of<DocumentsCubit>(context);
|
||||
final pageCount =
|
||||
documentsCubit.state.inferPageCount(pageSize: documentsCubit.state.filter.pageSize);
|
||||
if (pageCount <= pageKey + 1) {
|
||||
_pagingController.nextPageKey = null;
|
||||
}
|
||||
documentsCubit.loadMore();
|
||||
}
|
||||
|
||||
void _onSelected(DocumentModel model) {
|
||||
BlocProvider.of<DocumentsCubit>(context).toggleDocumentSelection(model);
|
||||
}
|
||||
|
||||
Future<void> _onRefresh() {
|
||||
final documentsCubit = BlocProvider.of<DocumentsCubit>(context);
|
||||
return documentsCubit
|
||||
.updateFilter(filter: documentsCubit.state.filter.copyWith(page: 1))
|
||||
.onError<ErrorMessage>((error, _) {
|
||||
showSnackBar(context, translateError(context, error.code));
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return WillPopScope(
|
||||
onWillPop: () async {
|
||||
if (_panelController.isPanelOpen) {
|
||||
FocusScope.of(context).unfocus();
|
||||
_panelController.close();
|
||||
return false;
|
||||
}
|
||||
final docBloc = BlocProvider.of<DocumentsCubit>(context);
|
||||
if (docBloc.state.selection.isNotEmpty) {
|
||||
docBloc.resetSelection();
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
},
|
||||
child: BlocConsumer<ConnectivityCubit, ConnectivityState>(
|
||||
listenWhen: (previous, current) =>
|
||||
previous != ConnectivityState.connected && current == ConnectivityState.connected,
|
||||
listener: (context, state) {
|
||||
BlocProvider.of<DocumentsCubit>(context).loadDocuments();
|
||||
},
|
||||
builder: (context, connectivityState) {
|
||||
return Scaffold(
|
||||
drawer: BlocProvider.value(
|
||||
value: BlocProvider.of<AuthenticationCubit>(context),
|
||||
child: const InfoDrawer(),
|
||||
),
|
||||
resizeToAvoidBottomInset: true,
|
||||
appBar: connectivityState == ConnectivityState.connected ? null : const OfflineBanner(),
|
||||
body: SlidingUpPanel(
|
||||
backdropEnabled: true,
|
||||
parallaxEnabled: true,
|
||||
parallaxOffset: .5,
|
||||
controller: _panelController,
|
||||
defaultPanelState: PanelState.CLOSED,
|
||||
minHeight: 48,
|
||||
maxHeight: MediaQuery.of(context).size.height -
|
||||
kBottomNavigationBarHeight -
|
||||
2 * kToolbarHeight,
|
||||
borderRadius: const BorderRadius.only(
|
||||
topLeft: Radius.circular(16),
|
||||
topRight: Radius.circular(16),
|
||||
),
|
||||
body: _buildBody(connectivityState),
|
||||
color: Theme.of(context).scaffoldBackgroundColor,
|
||||
panelBuilder: (scrollController) => DocumentFilterPanel(
|
||||
panelController: _panelController,
|
||||
scrollController: scrollController,
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildBody(ConnectivityState connectivityState) {
|
||||
return BlocBuilder<DocumentsCubit, DocumentsState>(
|
||||
builder: (context, state) {
|
||||
// Some ugly tricks to make it work with bloc, update pageController
|
||||
_pagingController.value = PagingState(
|
||||
itemList: state.documents,
|
||||
nextPageKey: state.nextPageNumber,
|
||||
);
|
||||
|
||||
late Widget child;
|
||||
switch (_viewType) {
|
||||
case ViewType.list:
|
||||
child = DocumentListView(
|
||||
onTap: _openDocumentDetails,
|
||||
state: state,
|
||||
onSelected: _onSelected,
|
||||
pagingController: _pagingController,
|
||||
hasInternetConnection: connectivityState == ConnectivityState.connected,
|
||||
);
|
||||
break;
|
||||
case ViewType.grid:
|
||||
child = DocumentGridView(
|
||||
onTap: _openDocumentDetails,
|
||||
state: state,
|
||||
onSelected: _onSelected,
|
||||
pagingController: _pagingController,
|
||||
hasInternetConnection: connectivityState == ConnectivityState.connected);
|
||||
break;
|
||||
}
|
||||
|
||||
if (state.isLoaded && state.documents.isEmpty) {
|
||||
child = SliverToBoxAdapter(
|
||||
child: DocumentsEmptyState(
|
||||
state: state,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
return RefreshIndicator(
|
||||
onRefresh: _onRefresh,
|
||||
child: Container(
|
||||
padding: const EdgeInsets.only(
|
||||
bottom: 142,
|
||||
), // Prevents panel from hiding scrollable content
|
||||
child: CustomScrollView(
|
||||
slivers: [
|
||||
DocumentsPageAppBar(
|
||||
actions: [
|
||||
const SortDocumentsButton(),
|
||||
IconButton(
|
||||
icon: Icon(
|
||||
_viewType == ViewType.grid ? Icons.list : Icons.grid_view,
|
||||
),
|
||||
onPressed: () => setState(() => _viewType = _viewType.toggle()),
|
||||
),
|
||||
],
|
||||
),
|
||||
child
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
void _openDocumentDetails(DocumentModel model) {
|
||||
Navigator.push(
|
||||
context,
|
||||
MaterialPageRoute(
|
||||
builder: (context) => MultiBlocProvider(
|
||||
providers: [
|
||||
BlocProvider.value(value: getIt<DocumentsCubit>()),
|
||||
BlocProvider.value(value: getIt<CorrespondentCubit>()),
|
||||
BlocProvider.value(value: getIt<DocumentTypeCubit>()),
|
||||
BlocProvider.value(value: getIt<TagCubit>()),
|
||||
BlocProvider.value(value: getIt<StoragePathCubit>()),
|
||||
],
|
||||
child: DocumentDetailsPage(
|
||||
documentId: model.id,
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
enum ViewType {
|
||||
grid,
|
||||
list;
|
||||
|
||||
ViewType toggle() {
|
||||
return this == grid ? list : grid;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_paperless_mobile/features/documents/model/document.model.dart';
|
||||
import 'package:flutter_paperless_mobile/generated/l10n.dart';
|
||||
|
||||
class DeleteDocumentConfirmationDialog extends StatelessWidget {
|
||||
final DocumentModel document;
|
||||
const DeleteDocumentConfirmationDialog({super.key, required this.document});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return AlertDialog(
|
||||
title: Text(S.of(context).documentsPageSelectionBulkDeleteDialogTitle),
|
||||
content: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Text(
|
||||
S.of(context).documentsPageSelectionBulkDeleteDialogWarningTextOne,
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
Text(
|
||||
document.title,
|
||||
maxLines: 2,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: const TextStyle(
|
||||
fontWeight: FontWeight.w700,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
Text(S.of(context).documentsPageSelectionBulkDeleteDialogContinueText),
|
||||
],
|
||||
),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.pop(context, false),
|
||||
child: Text(S.of(context).genericActionCancelLabel),
|
||||
),
|
||||
TextButton(
|
||||
style: ButtonStyle(
|
||||
foregroundColor: MaterialStateProperty.all(Theme.of(context).colorScheme.error),
|
||||
),
|
||||
onPressed: () {
|
||||
Navigator.pop(context, true);
|
||||
},
|
||||
child: Text(S.of(context).genericActionDeleteLabel),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
45
lib/features/documents/view/widgets/document_preview.dart
Normal file
45
lib/features/documents/view/widgets/document_preview.dart
Normal file
@@ -0,0 +1,45 @@
|
||||
import 'package:cached_network_image/cached_network_image.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_cache_manager/flutter_cache_manager.dart';
|
||||
import 'package:flutter_paperless_mobile/di_initializer.dart';
|
||||
import 'package:flutter_paperless_mobile/features/documents/repository/document_repository.dart';
|
||||
import 'package:shimmer/shimmer.dart';
|
||||
|
||||
class DocumentPreview extends StatelessWidget {
|
||||
final int id;
|
||||
final BoxFit fit;
|
||||
final Alignment alignment;
|
||||
final double borderRadius;
|
||||
|
||||
const DocumentPreview({
|
||||
Key? key,
|
||||
required this.id,
|
||||
this.fit = BoxFit.cover,
|
||||
this.alignment = Alignment.center,
|
||||
this.borderRadius = 8.0,
|
||||
}) : super(key: key);
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return
|
||||
// Hero(
|
||||
// tag: "document_$id",child:
|
||||
ClipRRect(
|
||||
borderRadius: BorderRadius.circular(borderRadius),
|
||||
child: CachedNetworkImage(
|
||||
fit: fit,
|
||||
alignment: Alignment.topCenter,
|
||||
cacheKey: "thumb_$id",
|
||||
imageUrl: getIt<DocumentRepository>().getThumbnailUrl(id),
|
||||
errorWidget: (ctxt, msg, __) => Text(msg),
|
||||
placeholder: (context, value) => Shimmer.fromColors(
|
||||
baseColor: Colors.grey[300]!,
|
||||
highlightColor: Colors.grey[100]!,
|
||||
child: const SizedBox(height: 100, width: 100),
|
||||
),
|
||||
cacheManager: getIt<CacheManager>(),
|
||||
),
|
||||
// ),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
import 'package:flutter_paperless_mobile/core/widgets/empty_state.dart';
|
||||
import 'package:flutter_paperless_mobile/extensions/flutter_extensions.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/bloc/saved_view_cubit.dart';
|
||||
import 'package:flutter_paperless_mobile/features/documents/model/document_filter.dart';
|
||||
import 'package:flutter_paperless_mobile/generated/l10n.dart';
|
||||
|
||||
class DocumentsEmptyState extends StatelessWidget {
|
||||
final DocumentsState state;
|
||||
const DocumentsEmptyState({
|
||||
Key? key,
|
||||
required this.state,
|
||||
}) : super(key: key);
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Center(
|
||||
child: EmptyState(
|
||||
title: S.of(context).documentsPageEmptyStateOopsText,
|
||||
subtitle: S.of(context).documentsPageEmptyStateNothingHereText,
|
||||
bottomChild: state.filter != DocumentFilter.initial
|
||||
? ElevatedButton(
|
||||
onPressed: () async {
|
||||
await BlocProvider.of<DocumentsCubit>(context).updateFilter();
|
||||
BlocProvider.of<SavedViewCubit>(context).resetSelection();
|
||||
},
|
||||
child: Text(
|
||||
S.of(context).documentsFilterPageResetFilterLabel,
|
||||
),
|
||||
).padded()
|
||||
: null,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
48
lib/features/documents/view/widgets/grid/document_grid.dart
Normal file
48
lib/features/documents/view/widgets/grid/document_grid.dart
Normal file
@@ -0,0 +1,48 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_paperless_mobile/core/widgets/documents_list_loading_widget.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/view/widgets/grid/document_grid_item.dart';
|
||||
import 'package:infinite_scroll_pagination/infinite_scroll_pagination.dart';
|
||||
|
||||
class DocumentGridView extends StatelessWidget {
|
||||
final void Function(DocumentModel model) onTap;
|
||||
final void Function(DocumentModel) onSelected;
|
||||
final PagingController<int, DocumentModel> pagingController;
|
||||
final DocumentsState state;
|
||||
final bool hasInternetConnection;
|
||||
|
||||
const DocumentGridView({
|
||||
super.key,
|
||||
required this.onTap,
|
||||
required this.pagingController,
|
||||
required this.state,
|
||||
required this.onSelected,
|
||||
required this.hasInternetConnection,
|
||||
});
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return PagedSliverGrid<int, DocumentModel>(
|
||||
gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount(
|
||||
crossAxisCount: 2,
|
||||
mainAxisSpacing: 4,
|
||||
crossAxisSpacing: 4,
|
||||
childAspectRatio: 1 / 2,
|
||||
),
|
||||
pagingController: pagingController,
|
||||
builderDelegate: PagedChildBuilderDelegate(
|
||||
itemBuilder: (context, item, index) {
|
||||
return DocumentGridItem(
|
||||
document: item,
|
||||
onTap: onTap,
|
||||
isSelected: state.selection.contains(item),
|
||||
onSelected: onSelected,
|
||||
isAtLeastOneSelected: state.selection.isNotEmpty,
|
||||
);
|
||||
},
|
||||
noItemsFoundIndicatorBuilder: (context) =>
|
||||
const DocumentsListLoadingWidget(), //TODO: Replace with grid loading widget
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_paperless_mobile/features/documents/model/document.model.dart';
|
||||
import 'package:flutter_paperless_mobile/features/documents/view/widgets/document_preview.dart';
|
||||
import 'package:flutter_paperless_mobile/features/labels/correspondent/view/widgets/correspondent_widget.dart';
|
||||
import 'package:flutter_paperless_mobile/features/labels/document_type/view/widgets/document_type_widget.dart';
|
||||
import 'package:flutter_paperless_mobile/features/labels/tags/view/widgets/tags_widget.dart';
|
||||
import 'package:intl/intl.dart';
|
||||
|
||||
class DocumentGridItem extends StatelessWidget {
|
||||
final DocumentModel document;
|
||||
final bool isSelected;
|
||||
final void Function(DocumentModel) onTap;
|
||||
final void Function(DocumentModel) onSelected;
|
||||
final bool isAtLeastOneSelected;
|
||||
|
||||
const DocumentGridItem({
|
||||
Key? key,
|
||||
required this.document,
|
||||
required this.onTap,
|
||||
required this.onSelected,
|
||||
required this.isSelected,
|
||||
required this.isAtLeastOneSelected,
|
||||
}) : super(key: key);
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return GestureDetector(
|
||||
onTap: _onTap,
|
||||
onLongPress: () => onSelected(document),
|
||||
child: AbsorbPointer(
|
||||
absorbing: isAtLeastOneSelected,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(8.0),
|
||||
child: Card(
|
||||
elevation: 1.0,
|
||||
color: isSelected
|
||||
? Theme.of(context).colorScheme.inversePrimary
|
||||
: Theme.of(context).cardColor,
|
||||
child: Column(
|
||||
children: [
|
||||
AspectRatio(
|
||||
aspectRatio: 1,
|
||||
child: DocumentPreview(
|
||||
id: document.id,
|
||||
borderRadius: 12.0,
|
||||
),
|
||||
),
|
||||
Expanded(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(8.0),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
CorrespondentWidget(correspondentId: document.correspondent),
|
||||
DocumentTypeWidget(documentTypeId: document.documentType),
|
||||
Text(
|
||||
document.title,
|
||||
maxLines: document.tags.isEmpty ? 3 : 2,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: Theme.of(context).textTheme.titleMedium,
|
||||
),
|
||||
const Spacer(),
|
||||
TagsWidget(
|
||||
tagIds: document.tags,
|
||||
isMultiLine: false,
|
||||
),
|
||||
Text(DateFormat.yMMMd(Intl.getCurrentLocale()).format(document.created)),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
void _onTap() {
|
||||
if (isAtLeastOneSelected || isSelected) {
|
||||
onSelected(document);
|
||||
} else {
|
||||
onTap(document);
|
||||
}
|
||||
}
|
||||
}
|
||||
44
lib/features/documents/view/widgets/list/document_list.dart
Normal file
44
lib/features/documents/view/widgets/list/document_list.dart
Normal file
@@ -0,0 +1,44 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_paperless_mobile/core/widgets/documents_list_loading_widget.dart';
|
||||
import 'package:flutter_paperless_mobile/core/widgets/offline_widget.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/view/widgets/list/document_list_item.dart';
|
||||
import 'package:infinite_scroll_pagination/infinite_scroll_pagination.dart';
|
||||
|
||||
class DocumentListView extends StatelessWidget {
|
||||
final void Function(DocumentModel model) onTap;
|
||||
final void Function(DocumentModel) onSelected;
|
||||
final PagingController<int, DocumentModel> pagingController;
|
||||
final DocumentsState state;
|
||||
final bool hasInternetConnection;
|
||||
|
||||
const DocumentListView({
|
||||
super.key,
|
||||
required this.onTap,
|
||||
required this.pagingController,
|
||||
required this.state,
|
||||
required this.onSelected,
|
||||
required this.hasInternetConnection,
|
||||
});
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return PagedSliverList<int, DocumentModel>(
|
||||
pagingController: pagingController,
|
||||
builderDelegate: PagedChildBuilderDelegate(
|
||||
animateTransitions: true,
|
||||
itemBuilder: (context, item, index) {
|
||||
return DocumentListItem(
|
||||
document: item,
|
||||
onTap: onTap,
|
||||
isSelected: state.selection.contains(item),
|
||||
onSelected: onSelected,
|
||||
isAtLeastOneSelected: state.selection.isNotEmpty,
|
||||
);
|
||||
},
|
||||
noItemsFoundIndicatorBuilder: (context) =>
|
||||
hasInternetConnection ? const DocumentsListLoadingWidget() : const OfflineWidget(),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_paperless_mobile/features/documents/model/document.model.dart';
|
||||
import 'package:flutter_paperless_mobile/features/documents/view/widgets/document_preview.dart';
|
||||
import 'package:flutter_paperless_mobile/features/labels/correspondent/view/widgets/correspondent_widget.dart';
|
||||
import 'package:flutter_paperless_mobile/features/labels/tags/view/widgets/tags_widget.dart';
|
||||
|
||||
class DocumentListItem extends StatelessWidget {
|
||||
static const a4AspectRatio = 1 / 1.4142;
|
||||
final DocumentModel document;
|
||||
final bool isSelected;
|
||||
final void Function(DocumentModel) onTap;
|
||||
final void Function(DocumentModel)? onSelected;
|
||||
final bool isAtLeastOneSelected;
|
||||
|
||||
const DocumentListItem({
|
||||
Key? key,
|
||||
required this.document,
|
||||
required this.onTap,
|
||||
this.onSelected,
|
||||
required this.isSelected,
|
||||
required this.isAtLeastOneSelected,
|
||||
}) : super(key: key);
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return SizedBox(
|
||||
child: ListTile(
|
||||
dense: true,
|
||||
selected: isSelected,
|
||||
onTap: () => _onTap(),
|
||||
selectedTileColor: Theme.of(context).colorScheme.inversePrimary,
|
||||
onLongPress: () => onSelected?.call(document),
|
||||
title: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
mainAxisAlignment: MainAxisAlignment.spaceAround,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
AbsorbPointer(
|
||||
absorbing: isAtLeastOneSelected,
|
||||
child: CorrespondentWidget(
|
||||
correspondentId: document.correspondent,
|
||||
afterSelected: () {},
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
Text(
|
||||
document.title,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
maxLines: document.tags.isEmpty ? 2 : 1,
|
||||
),
|
||||
],
|
||||
),
|
||||
subtitle: Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: 4),
|
||||
child: AbsorbPointer(
|
||||
absorbing: isAtLeastOneSelected,
|
||||
child: TagsWidget(
|
||||
tagIds: document.tags,
|
||||
isMultiLine: false,
|
||||
),
|
||||
),
|
||||
),
|
||||
isThreeLine: document.tags.isNotEmpty,
|
||||
leading: AspectRatio(
|
||||
aspectRatio: a4AspectRatio,
|
||||
child: GestureDetector(
|
||||
child: DocumentPreview(
|
||||
id: document.id,
|
||||
fit: BoxFit.cover,
|
||||
alignment: Alignment.topCenter,
|
||||
),
|
||||
),
|
||||
),
|
||||
contentPadding: const EdgeInsets.all(8.0),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
void _onTap() {
|
||||
if (isAtLeastOneSelected || isSelected) {
|
||||
onSelected?.call(document);
|
||||
} else {
|
||||
onTap(document);
|
||||
}
|
||||
}
|
||||
}
|
||||
21
lib/features/documents/view/widgets/order_by_dropdown.dart
Normal file
21
lib/features/documents/view/widgets/order_by_dropdown.dart
Normal file
@@ -0,0 +1,21 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_form_builder/flutter_form_builder.dart';
|
||||
import 'package:flutter_paperless_mobile/features/documents/model/query_parameters/sort_field.dart';
|
||||
|
||||
class OrderByDropdown extends StatefulWidget {
|
||||
static const fkOrderBy = "orderBy";
|
||||
const OrderByDropdown({super.key});
|
||||
|
||||
@override
|
||||
State<OrderByDropdown> createState() => _OrderByDropdownState();
|
||||
}
|
||||
|
||||
class _OrderByDropdownState extends State<OrderByDropdown> {
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return FormBuilderDropdown<SortField>(
|
||||
name: OrderByDropdown.fkOrderBy,
|
||||
items: const [],
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,530 @@
|
||||
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/bloc/saved_view_cubit.dart';
|
||||
import 'package:flutter_paperless_mobile/features/documents/model/document.model.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/sort_field.dart';
|
||||
import 'package:flutter_paperless_mobile/features/documents/model/query_parameters/query_type.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/document_type/bloc/document_type_cubit.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_filter.dart';
|
||||
import 'package:flutter_paperless_mobile/features/documents/view/widgets/search/query_type_form_field.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/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/tags/view/widgets/tags_form_field.dart';
|
||||
import 'package:flutter_paperless_mobile/features/labels/view/widgets/label_form_field.dart';
|
||||
import 'package:flutter_paperless_mobile/features/scan/view/document_upload_page.dart';
|
||||
import 'package:flutter_paperless_mobile/generated/l10n.dart';
|
||||
import 'package:intl/intl.dart';
|
||||
import 'package:sliding_up_panel/sliding_up_panel.dart';
|
||||
|
||||
enum DateRangeSelection { before, after }
|
||||
|
||||
class DocumentFilterPanel extends StatefulWidget {
|
||||
final PanelController panelController;
|
||||
final ScrollController scrollController;
|
||||
|
||||
const DocumentFilterPanel({
|
||||
Key? key,
|
||||
required this.panelController,
|
||||
required this.scrollController,
|
||||
}) : super(key: key);
|
||||
|
||||
@override
|
||||
State<DocumentFilterPanel> createState() => _DocumentFilterPanelState();
|
||||
}
|
||||
|
||||
class _DocumentFilterPanelState extends State<DocumentFilterPanel> {
|
||||
static const fkCorrespondent = DocumentModel.correspondentKey;
|
||||
static const fkDocumentType = DocumentModel.documentTypeKey;
|
||||
static const fkStoragePath = DocumentModel.storagePathKey;
|
||||
static const fkQuery = "query";
|
||||
static const fkCreatedAt = DocumentModel.createdKey;
|
||||
static const fkAddedAt = DocumentModel.addedKey;
|
||||
|
||||
static const _sortFields = [
|
||||
SortField.created,
|
||||
SortField.added,
|
||||
SortField.modified,
|
||||
SortField.title,
|
||||
SortField.correspondentName,
|
||||
SortField.documentType,
|
||||
SortField.archiveSerialNumber
|
||||
];
|
||||
|
||||
final _formKey = GlobalKey<FormBuilderState>();
|
||||
bool _isQueryLoading = false;
|
||||
|
||||
DateTimeRange? _dateTimeRangeOfNullable(DateTime? start, DateTime? end) {
|
||||
if (start == null && end == null) {
|
||||
return null;
|
||||
}
|
||||
if (start != null && end != null) {
|
||||
return DateTimeRange(start: start, end: end);
|
||||
}
|
||||
assert(start != null || end != null);
|
||||
final singleDate = (start ?? end)!;
|
||||
return DateTimeRange(start: singleDate, end: singleDate);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return BlocConsumer<DocumentsCubit, DocumentsState>(
|
||||
listener: (context, state) {
|
||||
// Set initial values, otherwise they would not automatically update.
|
||||
_patchFromFilter(state.filter);
|
||||
},
|
||||
builder: (context, state) {
|
||||
return FormBuilder(
|
||||
key: _formKey,
|
||||
child: MediaQuery.removePadding(
|
||||
context: context,
|
||||
removeTop: true,
|
||||
child: Column(
|
||||
children: [
|
||||
Stack(
|
||||
alignment: Alignment.center,
|
||||
children: [
|
||||
_buildDragLine(),
|
||||
Align(
|
||||
alignment: Alignment.topRight,
|
||||
child: TextButton.icon(
|
||||
icon: const Icon(Icons.refresh),
|
||||
label: Text(S.of(context).documentsFilterPageResetFilterLabel),
|
||||
onPressed: () => _resetFilter(context),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(
|
||||
height: 8.0,
|
||||
),
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
Text(
|
||||
S.of(context).documentsFilterPageTitle,
|
||||
style: Theme.of(context).textTheme.titleLarge,
|
||||
),
|
||||
TextButton(
|
||||
onPressed: _onApplyFilter,
|
||||
child: Text(S.of(context).documentsFilterPageApplyFilterLabel),
|
||||
),
|
||||
],
|
||||
).padded(),
|
||||
Expanded(
|
||||
child: ListView(
|
||||
controller: widget.scrollController,
|
||||
children: [
|
||||
const SizedBox(
|
||||
height: 16.0,
|
||||
),
|
||||
Align(
|
||||
alignment: Alignment.centerLeft,
|
||||
child: Text(S.of(context).documentsFilterPageSearchLabel),
|
||||
).padded(),
|
||||
_buildQueryFormField(state),
|
||||
_buildSortByChipsList(context, state),
|
||||
Align(
|
||||
alignment: Alignment.centerLeft,
|
||||
child: Text(S.of(context).documentsFilterPageAdvancedLabel),
|
||||
).padded(),
|
||||
_buildCreatedDateRangePickerFormField(state).padded(),
|
||||
_buildAddedDateRangePickerFormField(state).padded(),
|
||||
_buildCorrespondentFormField(state).padded(),
|
||||
_buildDocumentTypeFormField(state).padded(),
|
||||
_buildStoragePathFormField(state).padded(),
|
||||
TagFormField(
|
||||
name: DocumentModel.tagsKey,
|
||||
initialValue: state.filter.tags,
|
||||
).padded(),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
void _resetFilter(BuildContext context) async {
|
||||
FocusScope.of(context).unfocus();
|
||||
await BlocProvider.of<DocumentsCubit>(context).updateFilter();
|
||||
BlocProvider.of<SavedViewCubit>(context).resetSelection();
|
||||
if (!widget.panelController.isPanelClosed) {
|
||||
widget.panelController.close();
|
||||
}
|
||||
}
|
||||
|
||||
Widget _buildDocumentTypeFormField(DocumentsState docState) {
|
||||
return BlocBuilder<DocumentTypeCubit, Map<int, DocumentType>>(
|
||||
builder: (context, state) {
|
||||
return LabelFormField<DocumentType, DocumentTypeQuery>(
|
||||
formBuilderState: _formKey.currentState,
|
||||
name: fkDocumentType,
|
||||
state: state,
|
||||
label: S.of(context).documentDocumentTypePropertyLabel,
|
||||
initialValue: docState.filter.documentType,
|
||||
queryParameterIdBuilder: DocumentTypeQuery.fromId,
|
||||
queryParameterNotAssignedBuilder: DocumentTypeQuery.notAssigned,
|
||||
prefixIcon: const Icon(Icons.description_outlined),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildStoragePathFormField(DocumentsState docState) {
|
||||
return BlocBuilder<StoragePathCubit, Map<int, StoragePath>>(
|
||||
builder: (context, state) {
|
||||
return LabelFormField<StoragePath, StoragePathQuery>(
|
||||
formBuilderState: _formKey.currentState,
|
||||
name: fkStoragePath,
|
||||
state: state,
|
||||
label: S.of(context).documentStoragePathPropertyLabel,
|
||||
initialValue: docState.filter.storagePath,
|
||||
queryParameterIdBuilder: StoragePathQuery.fromId,
|
||||
queryParameterNotAssignedBuilder: StoragePathQuery.notAssigned,
|
||||
prefixIcon: const Icon(Icons.folder_outlined),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildQueryFormField(DocumentsState state) {
|
||||
final queryType = _formKey.currentState?.getRawValue(QueryTypeFormField.fkQueryType) ??
|
||||
QueryType.titleAndContent;
|
||||
late String label;
|
||||
switch (queryType) {
|
||||
case QueryType.title:
|
||||
label = S.of(context).documentsFilterPageQueryOptionsTitleLabel;
|
||||
break;
|
||||
case QueryType.titleAndContent:
|
||||
label = S.of(context).documentsFilterPageQueryOptionsTitleAndContentLabel;
|
||||
break;
|
||||
case QueryType.extended:
|
||||
label = S.of(context).documentsFilterPageQueryOptionsExtendedLabel;
|
||||
break;
|
||||
}
|
||||
|
||||
return FormBuilderTextField(
|
||||
name: fkQuery,
|
||||
textInputAction: TextInputAction.done,
|
||||
decoration: InputDecoration(
|
||||
prefixIcon: const Icon(Icons.search_outlined),
|
||||
labelText: label,
|
||||
suffixIcon: QueryTypeFormField(
|
||||
initialValue: state.filter.queryType,
|
||||
afterSelected: (queryType) => setState(() {}),
|
||||
),
|
||||
),
|
||||
initialValue: state.filter.queryText,
|
||||
).padded();
|
||||
}
|
||||
|
||||
Widget _buildDateRangePickerHelper(DocumentsState state, String formFieldKey) {
|
||||
return SingleChildScrollView(
|
||||
scrollDirection: Axis.horizontal,
|
||||
child: Row(
|
||||
children: [
|
||||
ActionChip(
|
||||
label: Text(
|
||||
S.of(context).documentsFilterPageDateRangeLastSevenDaysLabel,
|
||||
),
|
||||
onPressed: () {
|
||||
_formKey.currentState?.fields[formFieldKey]?.didChange(
|
||||
DateTimeRange(
|
||||
start: DateUtils.addDaysToDate(DateTime.now(), -7),
|
||||
end: DateTime.now(),
|
||||
),
|
||||
);
|
||||
},
|
||||
).padded(const EdgeInsets.only(right: 8.0)),
|
||||
ActionChip(
|
||||
label: Text(
|
||||
S.of(context).documentsFilterPageDateRangeLastMonthLabel,
|
||||
),
|
||||
onPressed: () {
|
||||
final now = DateTime.now();
|
||||
final firstDayOfLastMonth = DateUtils.addMonthsToMonthDate(now, -1);
|
||||
_formKey.currentState?.fields[formFieldKey]?.didChange(
|
||||
DateTimeRange(
|
||||
start: DateTime(firstDayOfLastMonth.year, firstDayOfLastMonth.month, now.day),
|
||||
end: DateTime.now(),
|
||||
),
|
||||
);
|
||||
},
|
||||
).padded(const EdgeInsets.only(right: 8.0)),
|
||||
ActionChip(
|
||||
label: Text(
|
||||
S.of(context).documentsFilterPageDateRangeLastThreeMonthsLabel,
|
||||
),
|
||||
onPressed: () {
|
||||
final now = DateTime.now();
|
||||
final firstDayOfLastMonth = DateUtils.addMonthsToMonthDate(now, -3);
|
||||
_formKey.currentState?.fields[formFieldKey]?.didChange(
|
||||
DateTimeRange(
|
||||
start: DateTime(
|
||||
firstDayOfLastMonth.year,
|
||||
firstDayOfLastMonth.month,
|
||||
now.day,
|
||||
),
|
||||
end: DateTime.now(),
|
||||
),
|
||||
);
|
||||
},
|
||||
).padded(const EdgeInsets.only(right: 8.0)),
|
||||
ActionChip(
|
||||
label: Text(
|
||||
S.of(context).documentsFilterPageDateRangeLastYearLabel,
|
||||
),
|
||||
onPressed: () {
|
||||
final now = DateTime.now();
|
||||
final firstDayOfLastMonth = DateUtils.addMonthsToMonthDate(now, -12);
|
||||
_formKey.currentState?.fields[formFieldKey]?.didChange(
|
||||
DateTimeRange(
|
||||
start: DateTime(
|
||||
firstDayOfLastMonth.year,
|
||||
firstDayOfLastMonth.month,
|
||||
now.day,
|
||||
),
|
||||
end: DateTime.now(),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildCorrespondentFormField(DocumentsState docState) {
|
||||
return BlocBuilder<CorrespondentCubit, Map<int, Correspondent>>(
|
||||
builder: (context, state) {
|
||||
return LabelFormField<Correspondent, CorrespondentQuery>(
|
||||
formBuilderState: _formKey.currentState,
|
||||
name: fkCorrespondent,
|
||||
state: state,
|
||||
label: S.of(context).documentCorrespondentPropertyLabel,
|
||||
initialValue: docState.filter.correspondent,
|
||||
queryParameterIdBuilder: CorrespondentQuery.fromId,
|
||||
queryParameterNotAssignedBuilder: CorrespondentQuery.notAssigned,
|
||||
prefixIcon: const Icon(Icons.person_outline),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildCreatedDateRangePickerFormField(DocumentsState state) {
|
||||
return Column(
|
||||
children: [
|
||||
FormBuilderDateRangePicker(
|
||||
initialValue: _dateTimeRangeOfNullable(
|
||||
state.filter.createdDateAfter,
|
||||
state.filter.createdDateBefore,
|
||||
),
|
||||
pickerBuilder: (context, child) {
|
||||
return Theme(
|
||||
data: ThemeData.light().copyWith(
|
||||
primaryColor: Theme.of(context).primaryColor,
|
||||
colorScheme: Theme.of(context).colorScheme,
|
||||
buttonTheme: Theme.of(context).buttonTheme,
|
||||
),
|
||||
child: child!,
|
||||
);
|
||||
},
|
||||
format: DateFormat.yMMMd(Localizations.localeOf(context).toString()),
|
||||
fieldStartLabelText: S.of(context).documentsFilterPageDateRangeFieldStartLabel,
|
||||
fieldEndLabelText: S.of(context).documentsFilterPageDateRangeFieldEndLabel,
|
||||
firstDate: DateTime.fromMicrosecondsSinceEpoch(0),
|
||||
lastDate: DateTime.now(),
|
||||
name: fkCreatedAt,
|
||||
decoration: InputDecoration(
|
||||
prefixIcon: const Icon(Icons.calendar_month_outlined),
|
||||
labelText: S.of(context).documentCreatedPropertyLabel,
|
||||
suffixIcon: IconButton(
|
||||
icon: const Icon(Icons.clear),
|
||||
onPressed: () => _formKey.currentState?.fields[fkCreatedAt]?.didChange(null),
|
||||
),
|
||||
),
|
||||
),
|
||||
_buildDateRangePickerHelper(state, fkCreatedAt),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildAddedDateRangePickerFormField(DocumentsState state) {
|
||||
return Column(
|
||||
children: [
|
||||
FormBuilderDateRangePicker(
|
||||
initialValue: _dateTimeRangeOfNullable(
|
||||
state.filter.addedDateAfter,
|
||||
state.filter.addedDateBefore,
|
||||
),
|
||||
pickerBuilder: (context, child) {
|
||||
return Theme(
|
||||
data: ThemeData.light().copyWith(
|
||||
primaryColor: Theme.of(context).primaryColor,
|
||||
colorScheme: Theme.of(context).colorScheme,
|
||||
buttonTheme: Theme.of(context).buttonTheme,
|
||||
),
|
||||
child: child!,
|
||||
);
|
||||
},
|
||||
format: DateFormat.yMMMd(Localizations.localeOf(context).toString()),
|
||||
fieldStartLabelText: S.of(context).documentsFilterPageDateRangeFieldStartLabel,
|
||||
fieldEndLabelText: S.of(context).documentsFilterPageDateRangeFieldEndLabel,
|
||||
firstDate: DateTime.fromMicrosecondsSinceEpoch(0),
|
||||
lastDate: DateTime.now(),
|
||||
name: fkAddedAt,
|
||||
decoration: InputDecoration(
|
||||
prefixIcon: const Icon(Icons.calendar_month_outlined),
|
||||
labelText: S.of(context).documentAddedPropertyLabel,
|
||||
suffixIcon: IconButton(
|
||||
icon: const Icon(Icons.clear),
|
||||
onPressed: () => _formKey.currentState?.fields[fkAddedAt]?.didChange(null),
|
||||
),
|
||||
),
|
||||
),
|
||||
_buildDateRangePickerHelper(state, fkAddedAt),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildDragLine() {
|
||||
return Container(
|
||||
width: 48,
|
||||
height: 5,
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.grey[300],
|
||||
borderRadius: const BorderRadius.all(Radius.circular(12.0)),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildSortByChipsList(BuildContext context, DocumentsState state) {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.all(8.0),
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.end,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
S.of(context).documentsPageOrderByLabel,
|
||||
),
|
||||
SizedBox(
|
||||
height: kToolbarHeight,
|
||||
child: ListView.separated(
|
||||
itemCount: _sortFields.length,
|
||||
scrollDirection: Axis.horizontal,
|
||||
separatorBuilder: (context, index) => const SizedBox(
|
||||
width: 8.0,
|
||||
),
|
||||
itemBuilder: (context, index) =>
|
||||
_buildActionChip(_sortFields[index], state.filter.sortField, context),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildActionChip(
|
||||
SortField sortField, SortField? currentlySelectedOrder, BuildContext context) {
|
||||
String text;
|
||||
switch (sortField) {
|
||||
case SortField.archiveSerialNumber:
|
||||
text = S.of(context).documentArchiveSerialNumberPropertyShortLabel;
|
||||
break;
|
||||
case SortField.correspondentName:
|
||||
text = S.of(context).documentCorrespondentPropertyLabel;
|
||||
break;
|
||||
case SortField.title:
|
||||
text = S.of(context).documentTitlePropertyLabel;
|
||||
break;
|
||||
case SortField.documentType:
|
||||
text = S.of(context).documentDocumentTypePropertyLabel;
|
||||
break;
|
||||
case SortField.created:
|
||||
text = S.of(context).documentCreatedPropertyLabel;
|
||||
break;
|
||||
case SortField.added:
|
||||
text = S.of(context).documentAddedPropertyLabel;
|
||||
break;
|
||||
case SortField.modified:
|
||||
text = S.of(context).documentModifiedPropertyLabel;
|
||||
break;
|
||||
}
|
||||
|
||||
final docBloc = BlocProvider.of<DocumentsCubit>(context);
|
||||
return ActionChip(
|
||||
label: Text(text),
|
||||
avatar: currentlySelectedOrder == sortField
|
||||
? const Icon(
|
||||
Icons.done,
|
||||
color: Colors.green,
|
||||
)
|
||||
: null,
|
||||
onPressed: () =>
|
||||
docBloc.updateFilter(filter: docBloc.state.filter.copyWith(sortField: sortField)),
|
||||
);
|
||||
}
|
||||
|
||||
void _onApplyFilter() {
|
||||
setState(() => _isQueryLoading = true);
|
||||
_formKey.currentState?.save();
|
||||
if (_formKey.currentState?.validate() ?? false) {
|
||||
final v = _formKey.currentState!.value;
|
||||
final docCubit = BlocProvider.of<DocumentsCubit>(context);
|
||||
DocumentFilter newFilter = docCubit.state.filter.copyWith(
|
||||
createdDateBefore: (v[fkCreatedAt] as DateTimeRange?)?.end,
|
||||
createdDateAfter: (v[fkCreatedAt] as DateTimeRange?)?.start,
|
||||
correspondent: v[fkCorrespondent] as CorrespondentQuery?,
|
||||
documentType: v[fkDocumentType] as DocumentTypeQuery?,
|
||||
storagePath: v[fkStoragePath] as StoragePathQuery?,
|
||||
tags: v[DocumentModel.tagsKey] as TagsQuery?,
|
||||
page: 1,
|
||||
queryText: v[fkQuery] as String?,
|
||||
addedDateBefore: (v[fkAddedAt] as DateTimeRange?)?.end,
|
||||
addedDateAfter: (v[fkAddedAt] as DateTimeRange?)?.start,
|
||||
queryType: v[QueryTypeFormField.fkQueryType] as QueryType,
|
||||
);
|
||||
BlocProvider.of<DocumentsCubit>(context).updateFilter(filter: newFilter).then((value) {
|
||||
BlocProvider.of<SavedViewCubit>(context).resetSelection();
|
||||
FocusScope.of(context).unfocus();
|
||||
widget.panelController.close();
|
||||
setState(() => _isQueryLoading = false);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
void _patchFromFilter(DocumentFilter f) {
|
||||
_formKey.currentState?.patchValue({
|
||||
fkCorrespondent: f.correspondent,
|
||||
fkDocumentType: f.documentType,
|
||||
fkQuery: f.queryText,
|
||||
fkStoragePath: f.storagePath,
|
||||
DocumentModel.tagsKey: f.tags,
|
||||
DocumentModel.titleKey: f.queryText,
|
||||
QueryTypeFormField.fkQueryType: f.queryType,
|
||||
fkCreatedAt: _dateTimeRangeOfNullable(
|
||||
f.createdDateAfter,
|
||||
f.createdDateBefore,
|
||||
),
|
||||
fkAddedAt: _dateTimeRangeOfNullable(
|
||||
f.addedDateAfter,
|
||||
f.addedDateBefore,
|
||||
),
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_form_builder/flutter_form_builder.dart';
|
||||
import 'package:flutter_paperless_mobile/features/documents/model/query_parameters/query_type.dart';
|
||||
import 'package:flutter_paperless_mobile/generated/l10n.dart';
|
||||
|
||||
class QueryTypeFormField extends StatelessWidget {
|
||||
static const fkQueryType = 'queryType';
|
||||
final QueryType? initialValue;
|
||||
final void Function(QueryType)? afterSelected;
|
||||
const QueryTypeFormField({
|
||||
super.key,
|
||||
this.initialValue,
|
||||
this.afterSelected,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return FormBuilderField<QueryType>(
|
||||
builder: (field) => PopupMenuButton<QueryType>(
|
||||
itemBuilder: (context) => [
|
||||
PopupMenuItem(
|
||||
child: ListTile(
|
||||
title: Text(S.of(context).documentsFilterPageQueryOptionsTitleAndContentLabel),
|
||||
),
|
||||
value: QueryType.titleAndContent,
|
||||
),
|
||||
PopupMenuItem(
|
||||
child: ListTile(
|
||||
title: Text(S.of(context).documentsFilterPageQueryOptionsTitleLabel),
|
||||
),
|
||||
value: QueryType.title,
|
||||
),
|
||||
PopupMenuItem(
|
||||
child: ListTile(
|
||||
title: Text(S.of(context).documentsFilterPageQueryOptionsExtendedLabel),
|
||||
),
|
||||
value: QueryType.extended,
|
||||
),
|
||||
//TODO: Add support for ASN queries
|
||||
],
|
||||
onSelected: (selection) {
|
||||
field.didChange(selection);
|
||||
afterSelected?.call(selection);
|
||||
},
|
||||
child: const Icon(Icons.more_vert),
|
||||
),
|
||||
initialValue: initialValue,
|
||||
name: QueryTypeFormField.fkQueryType,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_form_builder/flutter_form_builder.dart';
|
||||
import 'package:flutter_paperless_mobile/features/documents/model/document_filter.dart';
|
||||
import 'package:flutter_paperless_mobile/features/documents/model/saved_view.model.dart';
|
||||
import 'package:flutter_paperless_mobile/generated/l10n.dart';
|
||||
import 'package:form_builder_validators/form_builder_validators.dart';
|
||||
|
||||
class AddSavedViewPage extends StatefulWidget {
|
||||
final DocumentFilter currentFilter;
|
||||
const AddSavedViewPage({super.key, required this.currentFilter});
|
||||
|
||||
@override
|
||||
State<AddSavedViewPage> createState() => _AddSavedViewPageState();
|
||||
}
|
||||
|
||||
class _AddSavedViewPageState extends State<AddSavedViewPage> {
|
||||
static const fkName = 'name';
|
||||
static const fkShowOnDashboard = 'show_on_dashboard';
|
||||
static const fkShowInSidebar = 'show_in_sidebar';
|
||||
|
||||
final GlobalKey<FormBuilderState> _formKey = GlobalKey();
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Scaffold(
|
||||
appBar: AppBar(
|
||||
title: Text(S.of(context).savedViewCreateNewLabel),
|
||||
actions: [
|
||||
Padding(
|
||||
padding: const EdgeInsets.all(8.0),
|
||||
child: Tooltip(
|
||||
child: const Icon(Icons.info_outline),
|
||||
message: S.of(context).savedViewCreateTooltipText,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
floatingActionButton: FloatingActionButton.extended(
|
||||
icon: const Icon(Icons.add),
|
||||
onPressed: () => _onCreate(context),
|
||||
label: Text(S.of(context).genericActionCreateLabel),
|
||||
),
|
||||
body: Padding(
|
||||
padding: const EdgeInsets.all(8.0),
|
||||
child: FormBuilder(
|
||||
key: _formKey,
|
||||
child: ListView(
|
||||
children: [
|
||||
FormBuilderTextField(
|
||||
name: fkName,
|
||||
validator: FormBuilderValidators.required(), //TODO: INTL
|
||||
decoration: InputDecoration(
|
||||
label: Text(S.of(context).savedViewNameLabel),
|
||||
),
|
||||
),
|
||||
FormBuilderCheckbox(
|
||||
name: fkShowOnDashboard,
|
||||
initialValue: false,
|
||||
title: Text(S.of(context).savedViewShowOnDashboardLabel),
|
||||
),
|
||||
FormBuilderCheckbox(
|
||||
name: fkShowInSidebar,
|
||||
initialValue: false,
|
||||
title: Text(S.of(context).savedViewShowInSidebarLabel),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
void _onCreate(BuildContext context) {
|
||||
if (_formKey.currentState?.saveAndValidate() ?? false) {
|
||||
Navigator.pop(
|
||||
context,
|
||||
SavedView.fromDocumentFilter(
|
||||
widget.currentFilter,
|
||||
name: _formKey.currentState?.value[fkName] as String,
|
||||
showOnDashboard: _formKey.currentState?.value[fkShowOnDashboard] as bool,
|
||||
showInSidebar: _formKey.currentState?.value[fkShowInSidebar] as bool,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
import 'package:flutter/material.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/generated/l10n.dart';
|
||||
|
||||
class BulkDeleteConfirmationDialog extends StatelessWidget {
|
||||
static const _bulletPoint = "\u2022";
|
||||
final DocumentsState state;
|
||||
const BulkDeleteConfirmationDialog({Key? key, required this.state})
|
||||
: super(key: key);
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
assert(state.selection.isNotEmpty);
|
||||
return AlertDialog(
|
||||
title: Text(S.of(context).documentsPageSelectionBulkDeleteDialogTitle),
|
||||
content: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Text(
|
||||
//TODO: use plurals, didn't use because of crash... investigate later.
|
||||
state.selection.length == 1
|
||||
? S
|
||||
.of(context)
|
||||
.documentsPageSelectionBulkDeleteDialogWarningTextOne
|
||||
: S
|
||||
.of(context)
|
||||
.documentsPageSelectionBulkDeleteDialogWarningTextMany,
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
ConstrainedBox(
|
||||
constraints: const BoxConstraints(maxHeight: 150),
|
||||
child: ListView(
|
||||
shrinkWrap: true,
|
||||
children: state.selection.map(_buildBulletPoint).toList(),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
Text(
|
||||
S.of(context).documentsPageSelectionBulkDeleteDialogContinueText),
|
||||
],
|
||||
),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.pop(context, false),
|
||||
child: Text(S.of(context).genericActionCancelLabel),
|
||||
),
|
||||
TextButton(
|
||||
style: ButtonStyle(
|
||||
foregroundColor:
|
||||
MaterialStateProperty.all(Theme.of(context).colorScheme.error),
|
||||
),
|
||||
onPressed: () {
|
||||
Navigator.pop(context, true);
|
||||
},
|
||||
child: Text(S.of(context).genericActionDeleteLabel),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildBulletPoint(DocumentModel doc) {
|
||||
return Text(
|
||||
"\t$_bulletPoint ${doc.title}",
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: const TextStyle(
|
||||
fontWeight: FontWeight.w700,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
import 'package:flutter_paperless_mobile/core/model/error_message.dart';
|
||||
import 'package:flutter_paperless_mobile/features/documents/bloc/saved_view_cubit.dart';
|
||||
import 'package:flutter_paperless_mobile/features/documents/model/saved_view.model.dart';
|
||||
import 'package:flutter_paperless_mobile/generated/l10n.dart';
|
||||
import 'package:flutter_paperless_mobile/util.dart';
|
||||
|
||||
class ConfirmDeleteSavedViewDialog extends StatelessWidget {
|
||||
const ConfirmDeleteSavedViewDialog({
|
||||
Key? key,
|
||||
required this.view,
|
||||
}) : super(key: key);
|
||||
|
||||
final SavedView view;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return AlertDialog(
|
||||
title: Text(
|
||||
"Delete view " + view.name + "?",
|
||||
softWrap: true,
|
||||
),
|
||||
content: Text("Do you really want to delete this view?"),
|
||||
actions: [
|
||||
TextButton(
|
||||
child: Text(S.of(context).genericActionCancelLabel),
|
||||
onPressed: () => Navigator.pop(context, false),
|
||||
),
|
||||
TextButton(
|
||||
child: Text(
|
||||
S.of(context).genericActionDeleteLabel,
|
||||
style: TextStyle(color: Theme.of(context).colorScheme.error),
|
||||
),
|
||||
onPressed: () => Navigator.pop(context, true),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
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/bloc/documents_state.dart';
|
||||
import 'package:flutter_paperless_mobile/features/documents/view/widgets/selection/bulk_delete_confirmation_dialog.dart';
|
||||
import 'package:flutter_paperless_mobile/features/documents/view/widgets/selection/saved_view_selection_widget.dart';
|
||||
import 'package:flutter_paperless_mobile/generated/l10n.dart';
|
||||
import 'package:flutter_paperless_mobile/util.dart';
|
||||
|
||||
class DocumentsPageAppBar extends StatefulWidget with PreferredSizeWidget {
|
||||
final List<Widget> actions;
|
||||
|
||||
const DocumentsPageAppBar({
|
||||
super.key,
|
||||
this.actions = const [],
|
||||
});
|
||||
@override
|
||||
Size get preferredSize => const Size.fromHeight(kToolbarHeight);
|
||||
@override
|
||||
State<DocumentsPageAppBar> createState() => _DocumentsPageAppBarState();
|
||||
}
|
||||
|
||||
class _DocumentsPageAppBarState extends State<DocumentsPageAppBar> {
|
||||
static const _flexibleAreaHeight = kToolbarHeight + 48.0;
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return BlocBuilder<DocumentsCubit, DocumentsState>(
|
||||
builder: (context, documentsState) {
|
||||
if (documentsState.selection.isNotEmpty) {
|
||||
return SliverAppBar(
|
||||
snap: true,
|
||||
floating: true,
|
||||
pinned: true,
|
||||
expandedHeight: kToolbarHeight,
|
||||
leading: IconButton(
|
||||
icon: const Icon(Icons.close),
|
||||
onPressed: () => BlocProvider.of<DocumentsCubit>(context).resetSelection(),
|
||||
),
|
||||
title:
|
||||
Text('${documentsState.selection.length} ${S.of(context).documentsSelectedText}'),
|
||||
actions: [
|
||||
IconButton(
|
||||
icon: const Icon(Icons.delete),
|
||||
onPressed: () => _onDelete(context, documentsState),
|
||||
),
|
||||
],
|
||||
);
|
||||
} else {
|
||||
return SliverAppBar(
|
||||
expandedHeight: kToolbarHeight + _flexibleAreaHeight,
|
||||
pinned: true,
|
||||
flexibleSpace: const FlexibleSpaceBar(
|
||||
background: Padding(
|
||||
padding: EdgeInsets.all(8.0),
|
||||
child: SavedViewSelectionWidget(height: _flexibleAreaHeight),
|
||||
),
|
||||
),
|
||||
title: BlocBuilder<DocumentsCubit, DocumentsState>(
|
||||
builder: (context, state) {
|
||||
return Text(
|
||||
'${S.of(context).documentsPageTitle} (${_formatDocumentCount(state.count)})',
|
||||
);
|
||||
},
|
||||
),
|
||||
actions: widget.actions,
|
||||
);
|
||||
}
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
void _onDelete(BuildContext context, DocumentsState documentsState) async {
|
||||
final shouldDelete = await showDialog<bool>(
|
||||
context: context,
|
||||
builder: (context) => BulkDeleteConfirmationDialog(state: documentsState),
|
||||
);
|
||||
if (shouldDelete ?? false) {
|
||||
BlocProvider.of<DocumentsCubit>(context)
|
||||
.bulkRemoveDocuments(documentsState.selection)
|
||||
.then((_) => showSnackBar(context, S.of(context).documentsPageBulkDeleteSuccessfulText))
|
||||
.onError<ErrorMessage>(
|
||||
(error, _) => showSnackBar(context, translateError(context, error.code)));
|
||||
}
|
||||
}
|
||||
|
||||
String _formatDocumentCount(int count) {
|
||||
return count > 99 ? "99+" : count.toString();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,117 @@
|
||||
import 'dart:developer';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
import 'package:flutter_paperless_mobile/core/model/error_message.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/bloc/saved_view_cubit.dart';
|
||||
import 'package:flutter_paperless_mobile/features/documents/bloc/saved_view_state.dart';
|
||||
import 'package:flutter_paperless_mobile/features/documents/model/saved_view.model.dart';
|
||||
import 'package:flutter_paperless_mobile/features/documents/view/widgets/selection/add_saved_view_page.dart';
|
||||
import 'package:flutter_paperless_mobile/features/documents/view/widgets/selection/confirm_delete_saved_view_dialog.dart';
|
||||
import 'package:flutter_paperless_mobile/generated/l10n.dart';
|
||||
import 'package:flutter_paperless_mobile/util.dart';
|
||||
|
||||
class SavedViewSelectionWidget extends StatelessWidget {
|
||||
const SavedViewSelectionWidget({
|
||||
Key? key,
|
||||
required this.height,
|
||||
}) : super(key: key);
|
||||
|
||||
final double height;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Column(
|
||||
mainAxisAlignment: MainAxisAlignment.end,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
BlocBuilder<SavedViewCubit, SavedViewState>(
|
||||
builder: (context, state) {
|
||||
if (state.value.isEmpty) {
|
||||
return Text(S.of(context).savedViewsEmptyStateText);
|
||||
}
|
||||
return SizedBox(
|
||||
height: 48.0,
|
||||
child: ListView.separated(
|
||||
itemCount: state.value.length,
|
||||
scrollDirection: Axis.horizontal,
|
||||
itemBuilder: (context, index) {
|
||||
final view = state.value.values.elementAt(index);
|
||||
return GestureDetector(
|
||||
onLongPress: () => _onDelete(context, view),
|
||||
child: FilterChip(
|
||||
label: Text(state.value.values.toList()[index].name),
|
||||
selected: view.id == state.selectedSavedViewId,
|
||||
onSelected: (isSelected) => _onSelected(isSelected, context, view),
|
||||
),
|
||||
);
|
||||
},
|
||||
separatorBuilder: (context, index) => const SizedBox(
|
||||
width: 8.0,
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
Text(
|
||||
S.of(context).savedViewsLabel,
|
||||
style: Theme.of(context).textTheme.titleSmall,
|
||||
),
|
||||
TextButton.icon(
|
||||
icon: const Icon(Icons.add),
|
||||
onPressed: () => _onCreatePressed(context),
|
||||
label: Text(S.of(context).savedViewCreateNewLabel),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
void _onCreatePressed(BuildContext context) async {
|
||||
final newView = await Navigator.of(context).push<SavedView?>(
|
||||
MaterialPageRoute(
|
||||
builder: (context) => AddSavedViewPage(currentFilter: getIt<DocumentsCubit>().state.filter),
|
||||
),
|
||||
);
|
||||
if (newView != null) {
|
||||
try {
|
||||
BlocProvider.of<SavedViewCubit>(context).add(newView);
|
||||
} on ErrorMessage catch (error) {
|
||||
showError(context, error);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
void _onSelected(bool isSelected, BuildContext context, SavedView view) {
|
||||
if (isSelected) {
|
||||
BlocProvider.of<DocumentsCubit>(context).updateFilter(filter: view.toDocumentFilter());
|
||||
BlocProvider.of<SavedViewCubit>(context).selectView(view);
|
||||
} else {
|
||||
BlocProvider.of<DocumentsCubit>(context).updateFilter();
|
||||
BlocProvider.of<SavedViewCubit>(context).selectView(null);
|
||||
}
|
||||
}
|
||||
|
||||
void _onDelete(BuildContext context, SavedView view) async {
|
||||
{
|
||||
final delete = await showDialog<bool>(
|
||||
context: context,
|
||||
builder: (context) => ConfirmDeleteSavedViewDialog(view: view),
|
||||
) ??
|
||||
false;
|
||||
if (delete) {
|
||||
try {
|
||||
BlocProvider.of<SavedViewCubit>(context).remove(view);
|
||||
} on ErrorMessage catch (error) {
|
||||
showError(context, error);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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/sort_order.dart';
|
||||
import 'package:font_awesome_flutter/font_awesome_flutter.dart';
|
||||
|
||||
class SortDocumentsButton extends StatefulWidget {
|
||||
const SortDocumentsButton({
|
||||
Key? key,
|
||||
}) : super(key: key);
|
||||
|
||||
@override
|
||||
State<SortDocumentsButton> createState() => _SortDocumentsButtonState();
|
||||
}
|
||||
|
||||
class _SortDocumentsButtonState extends State<SortDocumentsButton> {
|
||||
bool _isLoading = false;
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return BlocBuilder<DocumentsCubit, DocumentsState>(
|
||||
builder: (context, state) {
|
||||
Widget child;
|
||||
if (_isLoading) {
|
||||
child = const FittedBox(
|
||||
fit: BoxFit.scaleDown,
|
||||
child: RefreshProgressIndicator(
|
||||
strokeWidth: 4.0,
|
||||
backgroundColor: Colors.transparent,
|
||||
),
|
||||
);
|
||||
} else {
|
||||
final bool isAscending = state.filter.sortOrder == SortOrder.ascending;
|
||||
child = IconButton(
|
||||
icon: FaIcon(
|
||||
isAscending ? FontAwesomeIcons.arrowDownAZ : FontAwesomeIcons.arrowUpZA,
|
||||
),
|
||||
onPressed: () async {
|
||||
setState(() => _isLoading = true);
|
||||
BlocProvider.of<DocumentsCubit>(context)
|
||||
.updateFilter(
|
||||
filter: state.filter.copyWith(sortOrder: state.filter.sortOrder.toggle()))
|
||||
.whenComplete(() => setState(() => _isLoading = false));
|
||||
},
|
||||
);
|
||||
}
|
||||
return SizedBox(
|
||||
height: Theme.of(context).iconTheme.size,
|
||||
width: Theme.of(context).iconTheme.size,
|
||||
child: child,
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
76
lib/features/home/view/home_page.dart
Normal file
76
lib/features/home/view/home_page.dart
Normal file
@@ -0,0 +1,76 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
import 'package:flutter_paperless_mobile/core/bloc/connectivity_cubit.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/bloc/saved_view_cubit.dart';
|
||||
import 'package:flutter_paperless_mobile/features/documents/model/saved_view.model.dart';
|
||||
import 'package:flutter_paperless_mobile/features/documents/view/pages/documents_page.dart';
|
||||
import 'package:flutter_paperless_mobile/features/home/view/widget/bottom_navigation_bar.dart';
|
||||
import 'package:flutter_paperless_mobile/features/home/view/widget/info_drawer.dart';
|
||||
import 'package:flutter_paperless_mobile/features/labels/correspondent/bloc/correspondents_cubit.dart';
|
||||
import 'package:flutter_paperless_mobile/features/labels/document_type/bloc/document_type_cubit.dart';
|
||||
import 'package:flutter_paperless_mobile/features/labels/storage_path/bloc/storage_path_cubit.dart';
|
||||
import 'package:flutter_paperless_mobile/features/labels/tags/bloc/tags_cubit.dart';
|
||||
import 'package:flutter_paperless_mobile/features/labels/view/pages/labels_page.dart';
|
||||
import 'package:flutter_paperless_mobile/features/scan/bloc/document_scanner_cubit.dart';
|
||||
import 'package:flutter_paperless_mobile/features/scan/view/scanner_page.dart';
|
||||
import 'package:flutter_paperless_mobile/util.dart';
|
||||
|
||||
class HomePage extends StatefulWidget {
|
||||
const HomePage({Key? key}) : super(key: key);
|
||||
|
||||
@override
|
||||
_HomePageState createState() => _HomePageState();
|
||||
}
|
||||
|
||||
class _HomePageState extends State<HomePage> {
|
||||
int _currentIndex = 0;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
initializeLabelData(context);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return BlocListener<ConnectivityCubit, ConnectivityState>(
|
||||
//Only re-initialize data if the connectivity changed from not connected to connected
|
||||
listenWhen: (previous, current) =>
|
||||
previous != ConnectivityState.connected && current == ConnectivityState.connected,
|
||||
listener: (context, state) {
|
||||
initializeLabelData(context);
|
||||
},
|
||||
child: Scaffold(
|
||||
key: rootScaffoldKey,
|
||||
bottomNavigationBar: BottomNavBar(
|
||||
selectedIndex: _currentIndex,
|
||||
onNavigationChanged: (index) => setState(() => _currentIndex = index),
|
||||
),
|
||||
drawer: const InfoDrawer(),
|
||||
body: [
|
||||
MultiBlocProvider(
|
||||
providers: [
|
||||
BlocProvider.value(value: getIt<DocumentsCubit>()),
|
||||
],
|
||||
child: const DocumentsPage(),
|
||||
),
|
||||
BlocProvider.value(
|
||||
value: getIt<DocumentScannerCubit>(),
|
||||
child: const ScannerPage(),
|
||||
),
|
||||
const LabelsPage(),
|
||||
][_currentIndex],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
initializeLabelData(BuildContext context) {
|
||||
BlocProvider.of<DocumentTypeCubit>(context).initialize();
|
||||
BlocProvider.of<CorrespondentCubit>(context).initialize();
|
||||
BlocProvider.of<TagCubit>(context).initialize();
|
||||
BlocProvider.of<StoragePathCubit>(context).initialize();
|
||||
BlocProvider.of<SavedViewCubit>(context).initialize();
|
||||
}
|
||||
}
|
||||
36
lib/features/home/view/widget/bottom_navigation_bar.dart
Normal file
36
lib/features/home/view/widget/bottom_navigation_bar.dart
Normal file
@@ -0,0 +1,36 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_paperless_mobile/generated/l10n.dart';
|
||||
|
||||
class BottomNavBar extends StatelessWidget {
|
||||
final int selectedIndex;
|
||||
final void Function(int) onNavigationChanged;
|
||||
|
||||
const BottomNavBar(
|
||||
{Key? key,
|
||||
required this.selectedIndex,
|
||||
required this.onNavigationChanged})
|
||||
: super(key: key);
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return NavigationBar(
|
||||
elevation: 4.0,
|
||||
onDestinationSelected: onNavigationChanged,
|
||||
selectedIndex: selectedIndex,
|
||||
destinations: [
|
||||
NavigationDestination(
|
||||
icon: const Icon(Icons.description),
|
||||
label: S.of(context).bottomNavDocumentsPageLabel,
|
||||
),
|
||||
NavigationDestination(
|
||||
icon: const Icon(Icons.document_scanner),
|
||||
label: S.of(context).bottomNavScannerPageLabel,
|
||||
),
|
||||
NavigationDestination(
|
||||
icon: const Icon(Icons.sell),
|
||||
label: S.of(context).bottomNavLabelsPageLabel,
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
117
lib/features/home/view/widget/info_drawer.dart
Normal file
117
lib/features/home/view/widget/info_drawer.dart
Normal file
@@ -0,0 +1,117 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
import 'package:flutter_paperless_mobile/features/settings/bloc/application_settings_cubit.dart';
|
||||
import 'package:flutter_paperless_mobile/di_initializer.dart';
|
||||
import 'package:flutter_paperless_mobile/features/labels/correspondent/bloc/correspondents_cubit.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/settings/view/settings_page.dart';
|
||||
import 'package:flutter_paperless_mobile/features/login/bloc/authentication_cubit.dart';
|
||||
import 'package:flutter_paperless_mobile/features/scan/bloc/document_scanner_cubit.dart';
|
||||
import 'package:flutter_paperless_mobile/features/labels/tags/bloc/tags_cubit.dart';
|
||||
import 'package:flutter_paperless_mobile/generated/l10n.dart';
|
||||
import 'package:flutter_paperless_mobile/util.dart';
|
||||
import 'package:url_launcher/url_launcher_string.dart';
|
||||
import 'package:flutter_paperless_mobile/extensions/flutter_extensions.dart';
|
||||
|
||||
class InfoDrawer extends StatelessWidget {
|
||||
const InfoDrawer({Key? key}) : super(key: key);
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Drawer(
|
||||
child: ListView(
|
||||
children: [
|
||||
DrawerHeader(
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
Image.asset(
|
||||
"assets/logos/paperless_logo_white.png",
|
||||
height: 32,
|
||||
width: 32,
|
||||
color: Theme.of(context).colorScheme.onPrimaryContainer,
|
||||
).padded(const EdgeInsets.only(right: 8.0)),
|
||||
Text(
|
||||
S.of(context).appTitleText,
|
||||
style: Theme.of(context)
|
||||
.textTheme
|
||||
.headline5!
|
||||
.copyWith(color: Theme.of(context).colorScheme.onPrimaryContainer),
|
||||
),
|
||||
],
|
||||
),
|
||||
Align(
|
||||
alignment: Alignment.bottomRight,
|
||||
child: BlocBuilder<AuthenticationCubit, AuthenticationState>(
|
||||
builder: (context, state) {
|
||||
return Text(
|
||||
state.authentication?.serverUrl.replaceAll(RegExp(r'https?://'), "") ?? "",
|
||||
textAlign: TextAlign.end,
|
||||
style: TextStyle(color: Theme.of(context).colorScheme.onPrimaryContainer),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
decoration: BoxDecoration(
|
||||
color: Theme.of(context).colorScheme.primaryContainer,
|
||||
),
|
||||
),
|
||||
ListTile(
|
||||
leading: const Icon(Icons.settings),
|
||||
title: Text(
|
||||
S.of(context).appDrawerSettingsLabel,
|
||||
),
|
||||
onTap: () => Navigator.of(context).push(
|
||||
MaterialPageRoute(
|
||||
builder: (context) => BlocProvider.value(
|
||||
value: getIt<ApplicationSettingsCubit>(),
|
||||
child: const SettingsPage(),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
const Divider(),
|
||||
ListTile(
|
||||
leading: const Icon(Icons.bug_report),
|
||||
title: Text(S.of(context).appDrawerReportBugLabel),
|
||||
onTap: () {
|
||||
launchUrlString("https://github.com/astubenbord/paperless-mobile/issues/new");
|
||||
},
|
||||
),
|
||||
const Divider(),
|
||||
AboutListTile(
|
||||
icon: const Icon(Icons.info),
|
||||
applicationIcon: const ImageIcon(AssetImage("assets/logos/paperless_logo_green.png")),
|
||||
applicationName: "Paperless Mobile",
|
||||
applicationVersion: kPackageInfo.version + "+" + kPackageInfo.buildNumber,
|
||||
aboutBoxChildren: [
|
||||
Text('${S.of(context).aboutDialogDevelopedByText} Anton Stubenbord'),
|
||||
],
|
||||
child: Text(S.of(context).appDrawerAboutLabel),
|
||||
),
|
||||
const Divider(),
|
||||
ListTile(
|
||||
leading: const Icon(Icons.logout),
|
||||
title: Text(S.of(context).appDrawerLogoutLabel),
|
||||
onTap: () {
|
||||
// Clear all bloc data
|
||||
BlocProvider.of<AuthenticationCubit>(context).logout();
|
||||
getIt<DocumentsCubit>().reset();
|
||||
getIt<CorrespondentCubit>().reset();
|
||||
getIt<DocumentTypeCubit>().reset();
|
||||
getIt<TagCubit>().reset();
|
||||
getIt<DocumentScannerCubit>().reset();
|
||||
},
|
||||
),
|
||||
const Divider(),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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';
|
||||
}
|
||||
@@ -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,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
}
|
||||
82
lib/features/labels/model/label.model.dart
Normal file
82
lib/features/labels/model/label.model.dart
Normal 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];
|
||||
}
|
||||
246
lib/features/labels/repository/label.repository.dart
Normal file
246
lib/features/labels/repository/label.repository.dart
Normal 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);
|
||||
}
|
||||
}
|
||||
32
lib/features/labels/repository/label_repository.dart
Normal file
32
lib/features/labels/repository/label_repository.dart
Normal 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();
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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';
|
||||
}
|
||||
@@ -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),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
22
lib/features/labels/tags/bloc/tags_cubit.dart
Normal file
22
lib/features/labels/tags/bloc/tags_cubit.dart
Normal 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);
|
||||
}
|
||||
96
lib/features/labels/tags/model/tag.model.dart
Normal file
96
lib/features/labels/tags/model/tag.model.dart
Normal 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);
|
||||
}
|
||||
35
lib/features/labels/tags/view/pages/add_tag_page.dart
Normal file
35
lib/features/labels/tags/view/pages/add_tag_page.dart
Normal 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),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
61
lib/features/labels/tags/view/pages/edit_tag_page.dart
Normal file
61
lib/features/labels/tags/view/pages/edit_tag_page.dart
Normal 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
|
||||
55
lib/features/labels/tags/view/widgets/tag_widget.dart
Normal file
55
lib/features/labels/tags/view/widgets/tag_widget.dart
Normal 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!();
|
||||
}
|
||||
}
|
||||
}
|
||||
99
lib/features/labels/tags/view/widgets/tags_form_field.dart
Normal file
99
lib/features/labels/tags/view/widgets/tags_form_field.dart
Normal 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,
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
55
lib/features/labels/tags/view/widgets/tags_widget.dart
Normal file
55
lib/features/labels/tags/view/widgets/tags_widget.dart
Normal 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,
|
||||
),
|
||||
);
|
||||
}
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
114
lib/features/labels/view/pages/add_label_page.dart
Normal file
114
lib/features/labels/view/pages/add_label_page.dart
Normal 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
124
lib/features/labels/view/pages/edit_label_page.dart
Normal file
124
lib/features/labels/view/pages/edit_label_page.dart
Normal 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
237
lib/features/labels/view/pages/labels_page.dart
Normal file
237
lib/features/labels/view/pages/labels_page.dart
Normal 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);
|
||||
},
|
||||
));
|
||||
}
|
||||
}
|
||||
161
lib/features/labels/view/widgets/label_form_field.dart
Normal file
161
lib/features/labels/view/widgets/label_form_field.dart
Normal 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.
|
||||
}
|
||||
}
|
||||
}
|
||||
70
lib/features/labels/view/widgets/label_item.dart
Normal file
70
lib/features/labels/view/widgets/label_item.dart
Normal 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);
|
||||
}
|
||||
}
|
||||
73
lib/features/labels/view/widgets/label_list_tile.dart
Normal file
73
lib/features/labels/view/widgets/label_list_tile.dart
Normal 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);
|
||||
}
|
||||
}
|
||||
52
lib/features/labels/view/widgets/label_tab_view.dart
Normal file
52
lib/features/labels/view/widgets/label_tab_view.dart
Normal 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(),
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
131
lib/features/login/bloc/authentication_cubit.dart
Normal file
131
lib/features/login/bloc/authentication_cubit.dart
Normal file
@@ -0,0 +1,131 @@
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
import 'package:flutter_paperless_mobile/core/model/error_message.dart';
|
||||
import 'package:flutter_paperless_mobile/core/store/local_vault.dart';
|
||||
import 'package:flutter_paperless_mobile/di_initializer.dart';
|
||||
import 'package:flutter_paperless_mobile/features/login/model/authentication_information.dart';
|
||||
import 'package:flutter_paperless_mobile/features/login/model/client_certificate.dart';
|
||||
import 'package:flutter_paperless_mobile/features/login/model/user_credentials.model.dart';
|
||||
import 'package:flutter_paperless_mobile/features/login/services/authentication.service.dart';
|
||||
import 'package:flutter_paperless_mobile/features/settings/model/application_settings_state.dart';
|
||||
import 'package:injectable/injectable.dart';
|
||||
|
||||
const authenticationKey = "authentication";
|
||||
|
||||
@singleton
|
||||
class AuthenticationCubit extends Cubit<AuthenticationState> {
|
||||
final LocalVault localStore;
|
||||
final AuthenticationService authenticationService;
|
||||
|
||||
AuthenticationCubit(this.localStore, this.authenticationService)
|
||||
: super(AuthenticationState.initial);
|
||||
|
||||
Future<void> initialize() {
|
||||
return restoreSessionState();
|
||||
}
|
||||
|
||||
Future<void> login({
|
||||
required UserCredentials credentials,
|
||||
required String serverUrl,
|
||||
ClientCertificate? clientCertificate,
|
||||
}) async {
|
||||
assert(credentials.username != null && credentials.password != null);
|
||||
try {
|
||||
registerSecurityContext(clientCertificate);
|
||||
} on TlsException catch (_) {
|
||||
throw const ErrorMessage(ErrorCode.invalidClientCertificateConfiguration);
|
||||
}
|
||||
emit(
|
||||
AuthenticationState(
|
||||
isAuthenticated: false,
|
||||
wasLoginStored: false,
|
||||
authentication: AuthenticationInformation(
|
||||
username: credentials.username!,
|
||||
password: credentials.password!,
|
||||
serverUrl: serverUrl,
|
||||
token: "",
|
||||
clientCertificate: clientCertificate,
|
||||
),
|
||||
),
|
||||
);
|
||||
final token = await authenticationService.login(
|
||||
username: credentials.username!,
|
||||
password: credentials.password!,
|
||||
serverUrl: serverUrl,
|
||||
);
|
||||
final auth = AuthenticationInformation(
|
||||
username: credentials.username!,
|
||||
password: credentials.password!,
|
||||
token: token,
|
||||
serverUrl: serverUrl,
|
||||
clientCertificate: clientCertificate,
|
||||
);
|
||||
|
||||
await localStore.storeAuthenticationInformation(auth);
|
||||
|
||||
emit(AuthenticationState(
|
||||
isAuthenticated: true,
|
||||
wasLoginStored: false,
|
||||
authentication: auth,
|
||||
));
|
||||
}
|
||||
|
||||
Future<void> restoreSessionState() async {
|
||||
final storedAuth = await localStore.loadAuthenticationInformation();
|
||||
final appSettings =
|
||||
await localStore.loadApplicationSettings() ?? ApplicationSettingsState.defaultSettings;
|
||||
|
||||
if (storedAuth == null || !storedAuth.isValid) {
|
||||
emit(AuthenticationState(isAuthenticated: false, wasLoginStored: false));
|
||||
} else {
|
||||
if (!appSettings.isLocalAuthenticationEnabled ||
|
||||
await authenticationService.authenticateLocalUser("Authenticate to log back in")) {
|
||||
registerSecurityContext(storedAuth.clientCertificate);
|
||||
emit(
|
||||
AuthenticationState(
|
||||
isAuthenticated: true,
|
||||
wasLoginStored: true,
|
||||
authentication: storedAuth,
|
||||
),
|
||||
);
|
||||
} else {
|
||||
emit(AuthenticationState(isAuthenticated: false, wasLoginStored: true));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> logout() async {
|
||||
await localStore.clear();
|
||||
emit(AuthenticationState.initial);
|
||||
}
|
||||
}
|
||||
|
||||
class AuthenticationState {
|
||||
final bool wasLoginStored;
|
||||
final bool isAuthenticated;
|
||||
final AuthenticationInformation? authentication;
|
||||
|
||||
static final AuthenticationState initial = AuthenticationState(
|
||||
wasLoginStored: false,
|
||||
isAuthenticated: false,
|
||||
);
|
||||
|
||||
AuthenticationState({
|
||||
required this.isAuthenticated,
|
||||
required this.wasLoginStored,
|
||||
this.authentication,
|
||||
});
|
||||
|
||||
AuthenticationState copyWith({
|
||||
bool? wasLoginStored,
|
||||
bool? isAuthenticated,
|
||||
AuthenticationInformation? authentication,
|
||||
}) {
|
||||
return AuthenticationState(
|
||||
isAuthenticated: isAuthenticated ?? this.isAuthenticated,
|
||||
wasLoginStored: wasLoginStored ?? this.wasLoginStored,
|
||||
authentication: authentication ?? this.authentication,
|
||||
);
|
||||
}
|
||||
}
|
||||
24
lib/features/login/bloc/local_authentication_cubit.dart
Normal file
24
lib/features/login/bloc/local_authentication_cubit.dart
Normal file
@@ -0,0 +1,24 @@
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
import 'package:flutter_paperless_mobile/core/model/error_message.dart';
|
||||
import 'package:flutter_paperless_mobile/di_initializer.dart';
|
||||
import 'package:local_auth/local_auth.dart';
|
||||
|
||||
class LocalAuthenticationCubit extends Cubit<LocalAuthenticationState> {
|
||||
LocalAuthenticationCubit() : super(LocalAuthenticationState(false));
|
||||
|
||||
Future<void> authorize(String localizedMessage) async {
|
||||
final isAuthenticationSuccessful = await getIt<LocalAuthentication>()
|
||||
.authenticate(localizedReason: localizedMessage);
|
||||
if (isAuthenticationSuccessful) {
|
||||
emit(LocalAuthenticationState(true));
|
||||
} else {
|
||||
throw const ErrorMessage(ErrorCode.biometricAuthenticationFailed);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
class LocalAuthenticationState {
|
||||
final bool isAuthorized;
|
||||
|
||||
LocalAuthenticationState(this.isAuthorized);
|
||||
}
|
||||
66
lib/features/login/model/authentication_information.dart
Normal file
66
lib/features/login/model/authentication_information.dart
Normal file
@@ -0,0 +1,66 @@
|
||||
import 'package:flutter_paperless_mobile/core/type/json.dart';
|
||||
import 'package:flutter_paperless_mobile/features/login/model/client_certificate.dart';
|
||||
|
||||
class AuthenticationInformation {
|
||||
static const usernameKey = 'username';
|
||||
static const passwordKey = 'password';
|
||||
static const tokenKey = 'token';
|
||||
static const serverUrlKey = 'serverUrl';
|
||||
static const clientCertificateKey = 'clientCertificate';
|
||||
|
||||
final String username;
|
||||
final String password;
|
||||
final String token;
|
||||
final String serverUrl;
|
||||
final ClientCertificate? clientCertificate;
|
||||
|
||||
AuthenticationInformation({
|
||||
required this.username,
|
||||
required this.password,
|
||||
required this.token,
|
||||
required this.serverUrl,
|
||||
this.clientCertificate,
|
||||
});
|
||||
|
||||
AuthenticationInformation.fromJson(JSON json)
|
||||
: username = json[usernameKey],
|
||||
password = json[passwordKey],
|
||||
token = json[tokenKey],
|
||||
serverUrl = json[serverUrlKey],
|
||||
clientCertificate = json[clientCertificateKey] != null
|
||||
? ClientCertificate.fromJson(json[clientCertificateKey])
|
||||
: null;
|
||||
|
||||
JSON toJson() {
|
||||
return {
|
||||
usernameKey: username,
|
||||
passwordKey: password,
|
||||
tokenKey: token,
|
||||
serverUrlKey: serverUrl,
|
||||
clientCertificateKey: clientCertificate?.toJson(),
|
||||
};
|
||||
}
|
||||
|
||||
bool get isValid {
|
||||
return serverUrl.isNotEmpty && token.isNotEmpty;
|
||||
}
|
||||
|
||||
AuthenticationInformation copyWith({
|
||||
String? username,
|
||||
String? password,
|
||||
String? token,
|
||||
String? serverUrl,
|
||||
ClientCertificate? clientCertificate,
|
||||
bool removeClientCertificate = false,
|
||||
bool? isLocalAuthenticationEnabled,
|
||||
}) {
|
||||
return AuthenticationInformation(
|
||||
username: username ?? this.username,
|
||||
password: password ?? this.password,
|
||||
token: token ?? this.token,
|
||||
serverUrl: serverUrl ?? this.serverUrl,
|
||||
clientCertificate: clientCertificate ??
|
||||
(removeClientCertificate ? null : this.clientCertificate),
|
||||
);
|
||||
}
|
||||
}
|
||||
39
lib/features/login/model/client_certificate.dart
Normal file
39
lib/features/login/model/client_certificate.dart
Normal file
@@ -0,0 +1,39 @@
|
||||
import 'dart:convert';
|
||||
import 'dart:typed_data';
|
||||
|
||||
import 'package:flutter_paperless_mobile/core/type/json.dart';
|
||||
|
||||
class ClientCertificate {
|
||||
static const bytesKey = 'bytes';
|
||||
static const passphraseKey = 'passphrase';
|
||||
|
||||
final Uint8List bytes;
|
||||
final String? passphrase;
|
||||
|
||||
ClientCertificate({required this.bytes, this.passphrase});
|
||||
|
||||
static ClientCertificate? nullable(Uint8List? bytes, {String? passphrase}) {
|
||||
if (bytes != null) {
|
||||
return ClientCertificate(bytes: bytes, passphrase: passphrase);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
JSON toJson() {
|
||||
return {
|
||||
bytesKey: base64Encode(bytes),
|
||||
passphraseKey: passphrase,
|
||||
};
|
||||
}
|
||||
|
||||
ClientCertificate.fromJson(JSON json)
|
||||
: bytes = base64Decode(json[bytesKey]),
|
||||
passphrase = json[passphraseKey];
|
||||
|
||||
ClientCertificate copyWith({Uint8List? bytes, String? passphrase}) {
|
||||
return ClientCertificate(
|
||||
bytes: bytes ?? this.bytes,
|
||||
passphrase: passphrase ?? this.passphrase,
|
||||
);
|
||||
}
|
||||
}
|
||||
13
lib/features/login/model/user_credentials.model.dart
Normal file
13
lib/features/login/model/user_credentials.model.dart
Normal file
@@ -0,0 +1,13 @@
|
||||
class UserCredentials {
|
||||
final String? username;
|
||||
final String? password;
|
||||
|
||||
UserCredentials({this.username, this.password});
|
||||
|
||||
UserCredentials copyWith({String? username, String? password}) {
|
||||
return UserCredentials(
|
||||
username: username ?? this.username,
|
||||
password: password ?? this.password,
|
||||
);
|
||||
}
|
||||
}
|
||||
57
lib/features/login/services/authentication.service.dart
Normal file
57
lib/features/login/services/authentication.service.dart
Normal file
@@ -0,0 +1,57 @@
|
||||
import 'dart:convert';
|
||||
|
||||
import 'package:flutter_paperless_mobile/core/model/error_message.dart';
|
||||
import 'package:flutter_paperless_mobile/core/store/local_vault.dart';
|
||||
import 'package:http/http.dart';
|
||||
import 'package:injectable/injectable.dart';
|
||||
import 'package:local_auth/local_auth.dart';
|
||||
|
||||
@singleton
|
||||
class AuthenticationService {
|
||||
final BaseClient httpClient;
|
||||
final LocalVault localStore;
|
||||
final LocalAuthentication localAuthentication;
|
||||
|
||||
AuthenticationService(
|
||||
this.localStore,
|
||||
this.localAuthentication,
|
||||
@Named("timeoutClient") this.httpClient,
|
||||
);
|
||||
|
||||
///
|
||||
/// Returns the authentication token.
|
||||
///
|
||||
Future<String> login({
|
||||
required String username,
|
||||
required String password,
|
||||
required String serverUrl,
|
||||
}) async {
|
||||
final response = await httpClient.post(
|
||||
Uri.parse("/api/token/"),
|
||||
body: {"username": username, "password": password},
|
||||
);
|
||||
if (response.statusCode == 200) {
|
||||
final data = jsonDecode(response.body);
|
||||
return data['token'];
|
||||
} else if (response.statusCode == 400 &&
|
||||
response.body.toLowerCase().contains("no required certificate was sent")) {
|
||||
throw const ErrorMessage(ErrorCode.invalidClientCertificateConfiguration);
|
||||
} else {
|
||||
throw const ErrorMessage(ErrorCode.authenticationFailed);
|
||||
}
|
||||
}
|
||||
|
||||
Future<bool> authenticateLocalUser(String localizedReason) async {
|
||||
if (await localAuthentication.isDeviceSupported()) {
|
||||
return await localAuthentication.authenticate(
|
||||
localizedReason: localizedReason,
|
||||
options: const AuthenticationOptions(
|
||||
stickyAuth: true,
|
||||
biometricOnly: true,
|
||||
useErrorDialogs: true,
|
||||
),
|
||||
);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
}
|
||||
103
lib/features/login/view/login_page.dart
Normal file
103
lib/features/login/view/login_page.dart
Normal file
@@ -0,0 +1,103 @@
|
||||
import 'package:flutter/material.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/di_initializer.dart';
|
||||
import 'package:flutter_paperless_mobile/extensions/flutter_extensions.dart';
|
||||
import 'package:flutter_paperless_mobile/features/login/bloc/authentication_cubit.dart';
|
||||
import 'package:flutter_paperless_mobile/features/login/view/widgets/client_certificate_form_field.dart';
|
||||
import 'package:flutter_paperless_mobile/features/login/view/widgets/server_address_form_field.dart';
|
||||
import 'package:flutter_paperless_mobile/features/login/view/widgets/user_credentials_form_field.dart';
|
||||
import 'package:flutter_paperless_mobile/generated/l10n.dart';
|
||||
import 'package:flutter_paperless_mobile/util.dart';
|
||||
|
||||
class LoginPage extends StatefulWidget {
|
||||
const LoginPage({Key? key}) : super(key: key);
|
||||
|
||||
@override
|
||||
State<LoginPage> createState() => _LoginPageState();
|
||||
}
|
||||
|
||||
class _LoginPageState extends State<LoginPage> {
|
||||
final _formKey = GlobalKey<FormBuilderState>();
|
||||
|
||||
bool _isLoginLoading = false;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Scaffold(
|
||||
resizeToAvoidBottomInset: true,
|
||||
appBar: AppBar(
|
||||
title: Text(S.of(context).loginPageTitle),
|
||||
bottom: _isLoginLoading
|
||||
? const PreferredSize(
|
||||
preferredSize: Size(double.infinity, 4),
|
||||
child: LinearProgressIndicator(),
|
||||
)
|
||||
: null,
|
||||
),
|
||||
body: Padding(
|
||||
padding: const EdgeInsets.all(8.0),
|
||||
child: FormBuilder(
|
||||
key: _formKey,
|
||||
child: ListView(
|
||||
children: [
|
||||
const ServerAddressFormField().padded(),
|
||||
const UserCredentialsFormField(),
|
||||
Align(
|
||||
alignment: Alignment.centerLeft,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.only(top: 16.0),
|
||||
child: Text(
|
||||
S.of(context).loginPageAdvancedLabel,
|
||||
style: Theme.of(context).textTheme.bodyText1,
|
||||
).padded(),
|
||||
),
|
||||
),
|
||||
const ClientCertificateFormField(),
|
||||
LayoutBuilder(builder: (context, constraints) {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.all(8.0),
|
||||
child: SizedBox(
|
||||
width: constraints.maxWidth,
|
||||
child: _buildLoginButton(),
|
||||
),
|
||||
);
|
||||
}),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildLoginButton() {
|
||||
return ElevatedButton(
|
||||
style: ButtonStyle(
|
||||
backgroundColor: MaterialStatePropertyAll(Theme.of(context).colorScheme.primaryContainer),
|
||||
elevation: const MaterialStatePropertyAll(0),
|
||||
),
|
||||
onPressed: _login,
|
||||
child: Text(
|
||||
S.of(context).loginPageLoginButtonLabel,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
void _login() async {
|
||||
FocusScope.of(context).unfocus();
|
||||
if (_formKey.currentState?.saveAndValidate() ?? false) {
|
||||
setState(() => _isLoginLoading = true);
|
||||
final form = _formKey.currentState?.value;
|
||||
getIt<AuthenticationCubit>()
|
||||
.login(
|
||||
credentials: form?[UserCredentialsFormField.fkCredentials],
|
||||
serverUrl: form?[ServerAddressFormField.fkServerAddress],
|
||||
clientCertificate: form?[ClientCertificateFormField.fkClientCertificate],
|
||||
) //TODO: Move Intro slider route push here!
|
||||
.onError<ErrorMessage>(
|
||||
(error, _) => showError(context, error),
|
||||
)
|
||||
.whenComplete(() => setState(() => _isLoginLoading = false));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,116 @@
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:file_picker/file_picker.dart';
|
||||
import 'package:flutter/material.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/login/model/client_certificate.dart';
|
||||
import 'package:flutter_paperless_mobile/features/login/view/widgets/password_text_field.dart';
|
||||
import 'package:flutter_paperless_mobile/generated/l10n.dart';
|
||||
|
||||
class ClientCertificateFormField extends StatefulWidget {
|
||||
static const fkClientCertificate = 'clientCertificate';
|
||||
const ClientCertificateFormField({Key? key}) : super(key: key);
|
||||
|
||||
@override
|
||||
State<ClientCertificateFormField> createState() => _ClientCertificateFormFieldState();
|
||||
}
|
||||
|
||||
class _ClientCertificateFormFieldState extends State<ClientCertificateFormField> {
|
||||
File? _selectedFile;
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return FormBuilderField<ClientCertificate?>(
|
||||
initialValue: null,
|
||||
validator: (value) {
|
||||
if (value == null) {
|
||||
return null;
|
||||
}
|
||||
assert(_selectedFile != null);
|
||||
if (_selectedFile?.path.split(".").last != 'pfx') {
|
||||
return S.of(context).loginPageClientCertificateSettingInvalidFileFormatValidationText;
|
||||
}
|
||||
return null;
|
||||
},
|
||||
builder: (field) {
|
||||
return ExpansionTile(
|
||||
title: Text(S.of(context).loginPageClientCertificateSettingLabel),
|
||||
subtitle: Text(S.of(context).loginPageClientCertificateSettingDescriptionText),
|
||||
children: [
|
||||
InputDecorator(
|
||||
decoration: InputDecoration(
|
||||
errorText: field.errorText,
|
||||
border: InputBorder.none,
|
||||
),
|
||||
child: Column(
|
||||
children: [
|
||||
ListTile(
|
||||
leading: ElevatedButton(
|
||||
onPressed: () => _onSelectFile(field),
|
||||
child: Text(S.of(context).genericActionSelectText),
|
||||
),
|
||||
title: _buildSelectedFileText(field),
|
||||
trailing: AbsorbPointer(
|
||||
absorbing: field.value == null,
|
||||
child: _selectedFile != null
|
||||
? IconButton(
|
||||
icon: const Icon(Icons.close),
|
||||
onPressed: () => setState(() {
|
||||
_selectedFile = null;
|
||||
field.didChange(null);
|
||||
}),
|
||||
)
|
||||
: null,
|
||||
),
|
||||
),
|
||||
if (_selectedFile != null) ...[
|
||||
ObscuredInputTextFormField(
|
||||
initialValue: field.value?.passphrase,
|
||||
onChanged: (value) => field.didChange(
|
||||
field.value?.copyWith(passphrase: value),
|
||||
),
|
||||
label: S.of(context).loginPageClientCertificatePassphraseLabel,
|
||||
).padded(),
|
||||
] else
|
||||
...[]
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
name: ClientCertificateFormField.fkClientCertificate,
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _onSelectFile(FormFieldState<ClientCertificate?> field) async {
|
||||
FilePickerResult? result = await FilePicker.platform.pickFiles();
|
||||
if (result != null && result.files.single.path != null) {
|
||||
File file = File(result.files.single.path!);
|
||||
setState(() {
|
||||
_selectedFile = file;
|
||||
});
|
||||
final changedValue = field.value?.copyWith(bytes: file.readAsBytesSync()) ??
|
||||
ClientCertificate(bytes: file.readAsBytesSync());
|
||||
field.didChange(changedValue);
|
||||
}
|
||||
}
|
||||
|
||||
Widget _buildSelectedFileText(FormFieldState<ClientCertificate?> field) {
|
||||
if (field.value == null) {
|
||||
assert(_selectedFile == null);
|
||||
return Text(
|
||||
S.of(context).loginPageClientCertificateSettingSelectFileText,
|
||||
style: TextStyle(color: Theme.of(context).hintColor),
|
||||
);
|
||||
} else {
|
||||
assert(_selectedFile != null);
|
||||
return Text(
|
||||
_selectedFile!.path.split("/").last,
|
||||
style: const TextStyle(
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
53
lib/features/login/view/widgets/password_text_field.dart
Normal file
53
lib/features/login/view/widgets/password_text_field.dart
Normal file
@@ -0,0 +1,53 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
class ObscuredInputTextFormField extends StatefulWidget {
|
||||
final String? initialValue;
|
||||
final String label;
|
||||
final void Function(String?) onChanged;
|
||||
final FormFieldValidator<String>? validator;
|
||||
|
||||
const ObscuredInputTextFormField({
|
||||
super.key,
|
||||
required this.onChanged,
|
||||
required this.label,
|
||||
this.validator,
|
||||
this.initialValue,
|
||||
});
|
||||
|
||||
@override
|
||||
State<ObscuredInputTextFormField> createState() => _ObscuredInputTextFormFieldState();
|
||||
}
|
||||
|
||||
class _ObscuredInputTextFormFieldState extends State<ObscuredInputTextFormField> {
|
||||
bool _showPassword = false;
|
||||
final FocusNode _passwordFocusNode = FocusNode();
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_passwordFocusNode.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return TextFormField(
|
||||
autovalidateMode: AutovalidateMode.onUserInteraction,
|
||||
validator: widget.validator,
|
||||
initialValue: widget.initialValue,
|
||||
focusNode: _passwordFocusNode,
|
||||
obscureText: !_showPassword,
|
||||
autocorrect: false,
|
||||
onChanged: widget.onChanged,
|
||||
autofillHints: const [AutofillHints.password],
|
||||
decoration: InputDecoration(
|
||||
label: Text(widget.label),
|
||||
suffixIcon: IconButton(
|
||||
icon: Icon(_showPassword ? Icons.visibility_off : Icons.visibility),
|
||||
onPressed: () => setState(() {
|
||||
_showPassword = !_showPassword;
|
||||
}),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_form_builder/flutter_form_builder.dart';
|
||||
import 'package:flutter_paperless_mobile/core/service/connectivity_status.service.dart';
|
||||
import 'package:flutter_paperless_mobile/di_initializer.dart';
|
||||
import 'package:flutter_paperless_mobile/generated/l10n.dart';
|
||||
import 'package:form_builder_validators/form_builder_validators.dart';
|
||||
|
||||
class ServerAddressFormField extends StatefulWidget {
|
||||
static const String fkServerAddress = "serverAddress";
|
||||
const ServerAddressFormField({
|
||||
Key? key,
|
||||
}) : super(key: key);
|
||||
|
||||
@override
|
||||
State<ServerAddressFormField> createState() => _ServerAddressFormFieldState();
|
||||
}
|
||||
|
||||
class _ServerAddressFormFieldState extends State<ServerAddressFormField> {
|
||||
ReachabilityStatus _reachabilityStatus = ReachabilityStatus.undefined;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return FormBuilderTextField(
|
||||
name: ServerAddressFormField.fkServerAddress,
|
||||
validator: FormBuilderValidators.required(
|
||||
errorText: S.of(context).loginPageServerUrlValidatorMessageText,
|
||||
),
|
||||
decoration: InputDecoration(
|
||||
suffixIcon: _buildIsReachableIcon(),
|
||||
hintText: "http://192.168.1.50:8000",
|
||||
labelText: S.of(context).loginPageServerUrlFieldLabel,
|
||||
),
|
||||
onSubmitted: _updateIsAddressReachableStatus,
|
||||
);
|
||||
}
|
||||
|
||||
Widget? _buildIsReachableIcon() {
|
||||
switch (_reachabilityStatus) {
|
||||
case ReachabilityStatus.reachable:
|
||||
return const Icon(
|
||||
Icons.done,
|
||||
color: Colors.green,
|
||||
);
|
||||
case ReachabilityStatus.notReachable:
|
||||
return Icon(
|
||||
Icons.close,
|
||||
color: Theme.of(context).colorScheme.error,
|
||||
);
|
||||
case ReachabilityStatus.testing:
|
||||
return const RefreshProgressIndicator();
|
||||
case ReachabilityStatus.undefined:
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
void _updateIsAddressReachableStatus(String? address) async {
|
||||
if (address == null || address.isEmpty) {
|
||||
setState(() {
|
||||
_reachabilityStatus = ReachabilityStatus.undefined;
|
||||
});
|
||||
return;
|
||||
}
|
||||
//https://stackoverflow.com/questions/49648022/check-whether-there-is-an-internet-connection-available-on-flutter-app
|
||||
setState(() => _reachabilityStatus = ReachabilityStatus.testing);
|
||||
final isReachable = await getIt<ConnectivityStatusService>().isServerReachable(address);
|
||||
if (isReachable) {
|
||||
setState(() => _reachabilityStatus = ReachabilityStatus.reachable);
|
||||
} else {
|
||||
setState(() => _reachabilityStatus = ReachabilityStatus.notReachable);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
enum ReachabilityStatus { reachable, notReachable, testing, undefined }
|
||||
@@ -0,0 +1,96 @@
|
||||
import 'package:flutter/material.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/login/model/user_credentials.model.dart';
|
||||
import 'package:flutter_paperless_mobile/features/login/view/widgets/password_text_field.dart';
|
||||
import 'package:flutter_paperless_mobile/generated/l10n.dart';
|
||||
import 'package:form_builder_validators/form_builder_validators.dart';
|
||||
|
||||
class UserCredentialsFormField extends StatefulWidget {
|
||||
static const fkCredentials = 'credentials';
|
||||
const UserCredentialsFormField({Key? key}) : super(key: key);
|
||||
|
||||
@override
|
||||
State<UserCredentialsFormField> createState() =>
|
||||
_UserCredentialsFormFieldState();
|
||||
}
|
||||
|
||||
class _UserCredentialsFormFieldState extends State<UserCredentialsFormField> {
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return FormBuilderField<UserCredentials?>(
|
||||
name: UserCredentialsFormField.fkCredentials,
|
||||
builder: (field) => AutofillGroup(
|
||||
child: Column(
|
||||
children: [
|
||||
TextFormField(
|
||||
textCapitalization: TextCapitalization.words,
|
||||
autovalidateMode: AutovalidateMode.onUserInteraction,
|
||||
// USERNAME
|
||||
autocorrect: false,
|
||||
onChanged: (username) => field.didChange(
|
||||
field.value?.copyWith(username: username) ??
|
||||
UserCredentials(username: username),
|
||||
),
|
||||
validator: FormBuilderValidators.required(
|
||||
errorText: S.of(context).loginPageUsernameValidatorMessageText,
|
||||
),
|
||||
autofillHints: const [AutofillHints.username],
|
||||
decoration: InputDecoration(
|
||||
label: Text(S.of(context).loginPageUsernameLabel),
|
||||
),
|
||||
),
|
||||
ObscuredInputTextFormField(
|
||||
label: S.of(context).loginPagePasswordFieldLabel,
|
||||
onChanged: (password) => field.didChange(
|
||||
field.value?.copyWith(password: password) ??
|
||||
UserCredentials(password: password),
|
||||
),
|
||||
validator: FormBuilderValidators.required(
|
||||
errorText: S.of(context).loginPagePasswordValidatorMessageText,
|
||||
),
|
||||
),
|
||||
].map((child) => child.padded()).toList(),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* AutofillGroup(
|
||||
child: Column(
|
||||
children: [
|
||||
FormBuilderTextField(
|
||||
name: fkUsername,
|
||||
focusNode: _focusNodes[fkUsername],
|
||||
onSubmitted: (_) {
|
||||
FocusScope.of(context).requestFocus(_focusNodes[fkPassword]);
|
||||
},
|
||||
validator: FormBuilderValidators.required(
|
||||
errorText: S.of(context).loginPageUsernameValidatorMessageText,
|
||||
),
|
||||
autofillHints: const [AutofillHints.username],
|
||||
decoration: InputDecoration(
|
||||
labelText: S.of(context).loginPageUsernameLabel,
|
||||
),
|
||||
).padded(),
|
||||
FormBuilderTextField(
|
||||
name: fkPassword,
|
||||
focusNode: _focusNodes[fkPassword],
|
||||
onSubmitted: (_) {
|
||||
FocusScope.of(context).unfocus();
|
||||
},
|
||||
autofillHints: const [AutofillHints.password],
|
||||
validator: FormBuilderValidators.required(
|
||||
errorText: S.of(context).loginPagePasswordValidatorMessageText,
|
||||
),
|
||||
obscureText: true,
|
||||
decoration: InputDecoration(
|
||||
labelText: S.of(context).loginPagePasswordFieldLabel,
|
||||
),
|
||||
).padded(),
|
||||
],
|
||||
),
|
||||
);
|
||||
*/
|
||||
34
lib/features/scan/bloc/document_scanner_cubit.dart
Normal file
34
lib/features/scan/bloc/document_scanner_cubit.dart
Normal file
@@ -0,0 +1,34 @@
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
import 'package:flutter_paperless_mobile/core/model/error_message.dart';
|
||||
import 'package:injectable/injectable.dart';
|
||||
|
||||
@singleton
|
||||
class DocumentScannerCubit extends Cubit<List<File>> {
|
||||
static List<File> initialState = [];
|
||||
|
||||
DocumentScannerCubit() : super(initialState);
|
||||
|
||||
void addScan(File file) => emit([...state, file]);
|
||||
|
||||
void removeScan(int fileIndex) {
|
||||
try {
|
||||
state[fileIndex].deleteSync();
|
||||
final scans = [...state];
|
||||
scans.removeAt(fileIndex);
|
||||
emit(scans);
|
||||
} catch (_) {
|
||||
addError(const ErrorMessage(ErrorCode.scanRemoveFailed));
|
||||
}
|
||||
}
|
||||
|
||||
void reset() {
|
||||
for (final doc in state) {
|
||||
doc.deleteSync();
|
||||
}
|
||||
imageCache.clear();
|
||||
emit(initialState);
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user