Merge pull request #273 from astubenbord/feat/less-restrictive-label-inputs-and-document-edit-improvements

feat: Make label fields less restrictive, improve change detection in document edit page
This commit is contained in:
Anton Stubenbord
2023-10-10 15:29:38 +02:00
committed by GitHub
25 changed files with 597 additions and 391 deletions

View File

@@ -9,14 +9,12 @@ class UnsavedChangesWarningDialog extends StatelessWidget {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return AlertDialog( return AlertDialog(
title: Text("Discard changes?"), title: Text(S.of(context)!.discardChanges),
content: Text( content: Text(S.of(context)!.discardChangesWarning),
"You have unsaved changes. Do you want to continue without saving? Your changes will be discarded.",
),
actions: [ actions: [
DialogCancelButton(), const DialogCancelButton(),
DialogConfirmButton( DialogConfirmButton(
label: S.of(context)!.continueLabel, label: S.of(context)!.discard,
), ),
], ],
); );

View File

@@ -44,7 +44,6 @@ class DocumentDetailsCubit extends Cubit<DocumentDetailsState> {
), ),
), ),
); );
loadMetaData();
} }
Future<void> delete(DocumentModel document) async { Future<void> delete(DocumentModel document) async {
@@ -60,13 +59,10 @@ class DocumentDetailsCubit extends Cubit<DocumentDetailsState> {
} }
Future<void> loadFullContent() async { Future<void> loadFullContent() async {
await Future.delayed(const Duration(seconds: 5));
final doc = await _api.find(state.document.id); final doc = await _api.find(state.document.id);
emit( _notifier.notifyUpdated(doc);
state.copyWith( emit(state.copyWith(isFullContentLoaded: true));
isFullContentLoaded: true,
fullContent: doc.content,
),
);
} }
Future<void> assignAsn( Future<void> assignAsn(

View File

@@ -6,7 +6,6 @@ class DocumentDetailsState with _$DocumentDetailsState {
required DocumentModel document, required DocumentModel document,
DocumentMetaData? metaData, DocumentMetaData? metaData,
@Default(false) bool isFullContentLoaded, @Default(false) bool isFullContentLoaded,
String? fullContent,
@Default({}) Map<int, Correspondent> correspondents, @Default({}) Map<int, Correspondent> correspondents,
@Default({}) Map<int, DocumentType> documentTypes, @Default({}) Map<int, DocumentType> documentTypes,
@Default({}) Map<int, Tag> tags, @Default({}) Map<int, Tag> tags,

View File

@@ -239,7 +239,6 @@ class _DocumentDetailsPageState extends State<DocumentDetailsPage> {
DocumentContentWidget( DocumentContentWidget(
isFullContentLoaded: state.isFullContentLoaded, isFullContentLoaded: state.isFullContentLoaded,
document: state.document, document: state.document,
fullContent: state.fullContent,
queryString: widget.titleAndContentQueryString, queryString: widget.titleAndContentQueryString,
), ),
], ],
@@ -302,22 +301,16 @@ class _DocumentDetailsPageState extends State<DocumentDetailsPage> {
if (!canEdit) { if (!canEdit) {
return const SizedBox.shrink(); return const SizedBox.shrink();
} }
return BlocBuilder<DocumentDetailsCubit, DocumentDetailsState>( final document = context.read<DocumentDetailsCubit>().state.document;
builder: (context, state) { return Tooltip(
// final _filteredSuggestions = message: S.of(context)!.editDocumentTooltip,
// state.suggestions?.documentDifference(state.document); preferBelow: false,
verticalOffset: 40,
return Tooltip( child: FloatingActionButton(
message: S.of(context)!.editDocumentTooltip, heroTag: "fab_document_details",
preferBelow: false, child: const Icon(Icons.edit),
verticalOffset: 40, onPressed: () => EditDocumentRoute(document).push(context),
child: FloatingActionButton( ),
heroTag: "fab_document_details",
child: const Icon(Icons.edit),
onPressed: () => EditDocumentRoute(state.document).push(context),
),
);
},
); );
} }

View File

@@ -2,44 +2,50 @@ import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:paperless_api/paperless_api.dart'; import 'package:paperless_api/paperless_api.dart';
import 'package:paperless_mobile/core/widgets/highlighted_text.dart'; import 'package:paperless_mobile/core/widgets/highlighted_text.dart';
import 'package:paperless_mobile/core/widgets/shimmer_placeholder.dart';
import 'package:paperless_mobile/extensions/flutter_extensions.dart';
import 'package:paperless_mobile/features/document_details/cubit/document_details_cubit.dart'; import 'package:paperless_mobile/features/document_details/cubit/document_details_cubit.dart';
import 'package:paperless_mobile/generated/l10n/app_localizations.dart'; import 'package:paperless_mobile/generated/l10n/app_localizations.dart';
class DocumentContentWidget extends StatelessWidget { class DocumentContentWidget extends StatelessWidget {
final bool isFullContentLoaded; final bool isFullContentLoaded;
final String? fullContent;
final String? queryString; final String? queryString;
final DocumentModel document; final DocumentModel document;
const DocumentContentWidget({ const DocumentContentWidget({
super.key, super.key,
required this.isFullContentLoaded, required this.isFullContentLoaded,
this.fullContent,
required this.document, required this.document,
this.queryString, this.queryString,
}); });
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final screenWidth = MediaQuery.sizeOf(context).width;
return SliverToBoxAdapter( return SliverToBoxAdapter(
child: Column( child: Column(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
HighlightedText( HighlightedText(
text: (isFullContentLoaded ? fullContent : document.content) ?? "", text: document.content ?? '',
highlights: queryString != null ? queryString!.split(" ") : [], highlights: queryString != null ? queryString!.split(" ") : [],
style: Theme.of(context).textTheme.bodyMedium, style: Theme.of(context).textTheme.bodyMedium,
caseSensitive: false, caseSensitive: false,
), ),
if (!isFullContentLoaded && (document.content ?? '').isNotEmpty) if (!isFullContentLoaded)
Align( ShimmerPlaceholder(
alignment: Alignment.bottomCenter, child: Column(
child: TextButton( crossAxisAlignment: CrossAxisAlignment.start,
child: Text(S.of(context)!.loadFullContent), children: [
onPressed: () { for (var scale in [0.5, 0.9, 0.5, 0.8, 0.9, 0.9])
context.read<DocumentDetailsCubit>().loadFullContent(); Container(
}, margin: const EdgeInsets.symmetric(vertical: 4),
width: screenWidth * scale,
height: 14,
color: Colors.white,
),
],
), ),
), ).paddedOnly(top: 4),
], ],
), ),
); );

View File

@@ -25,12 +25,19 @@ class DocumentEditCubit extends Cubit<DocumentEditState> {
_notifier.addListener(this, onUpdated: replace); _notifier.addListener(this, onUpdated: replace);
_labelRepository.addListener( _labelRepository.addListener(
this, this,
onChanged: (labels) => emit(state.copyWith( onChanged: (labels) {
correspondents: labels.correspondents, if (isClosed) {
documentTypes: labels.documentTypes, return;
storagePaths: labels.storagePaths, }
tags: labels.tags, emit(
)), state.copyWith(
correspondents: labels.correspondents,
documentTypes: labels.documentTypes,
storagePaths: labels.storagePaths,
tags: labels.tags,
),
);
},
); );
} }

View File

@@ -1,5 +1,6 @@
import 'dart:async'; import 'dart:async';
import 'package:collection/collection.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:flutter_form_builder/flutter_form_builder.dart'; import 'package:flutter_form_builder/flutter_form_builder.dart';
@@ -8,18 +9,18 @@ import 'package:go_router/go_router.dart';
import 'package:intl/intl.dart'; import 'package:intl/intl.dart';
import 'package:paperless_api/paperless_api.dart'; import 'package:paperless_api/paperless_api.dart';
import 'package:paperless_mobile/core/database/tables/local_user_account.dart'; import 'package:paperless_mobile/core/database/tables/local_user_account.dart';
import 'package:paperless_mobile/core/repository/label_repository.dart'; import 'package:paperless_mobile/core/notifier/document_changed_notifier.dart';
import 'package:paperless_mobile/core/widgets/dialog_utils/dialog_cancel_button.dart';
import 'package:paperless_mobile/core/widgets/dialog_utils/pop_with_unsaved_changes.dart'; import 'package:paperless_mobile/core/widgets/dialog_utils/pop_with_unsaved_changes.dart';
import 'package:paperless_mobile/core/workarounds/colored_chip.dart'; import 'package:paperless_mobile/core/workarounds/colored_chip.dart';
import 'package:paperless_mobile/extensions/flutter_extensions.dart'; import 'package:paperless_mobile/extensions/flutter_extensions.dart';
import 'package:paperless_mobile/features/document_edit/cubit/document_edit_cubit.dart'; import 'package:paperless_mobile/features/document_edit/cubit/document_edit_cubit.dart';
import 'package:paperless_mobile/features/edit_label/view/impl/add_correspondent_page.dart';
import 'package:paperless_mobile/features/edit_label/view/impl/add_document_type_page.dart';
import 'package:paperless_mobile/features/edit_label/view/impl/add_storage_path_page.dart';
import 'package:paperless_mobile/features/labels/tags/view/widgets/tags_form_field.dart'; import 'package:paperless_mobile/features/labels/tags/view/widgets/tags_form_field.dart';
import 'package:paperless_mobile/features/labels/view/widgets/label_form_field.dart'; import 'package:paperless_mobile/features/labels/view/widgets/label_form_field.dart';
import 'package:paperless_mobile/generated/l10n/app_localizations.dart'; import 'package:paperless_mobile/generated/l10n/app_localizations.dart';
import 'package:paperless_mobile/helpers/message_helpers.dart'; import 'package:paperless_mobile/helpers/message_helpers.dart';
import 'package:paperless_mobile/routes/typed/branches/labels_route.dart';
import 'package:paperless_mobile/routes/typed/shells/authenticated_route.dart';
class DocumentEditPage extends StatefulWidget { class DocumentEditPage extends StatefulWidget {
const DocumentEditPage({ const DocumentEditPage({
@@ -39,18 +40,79 @@ class _DocumentEditPageState extends State<DocumentEditPage> {
static const fkStoragePath = 'storagePath'; static const fkStoragePath = 'storagePath';
static const fkContent = 'content'; static const fkContent = 'content';
final GlobalKey<FormBuilderState> _formKey = GlobalKey(); final _formKey = GlobalKey<FormBuilderState>();
@override
void didUpdateWidget(covariant DocumentEditPage oldWidget) {
super.didUpdateWidget(oldWidget);
print("WIDGET CONFIGURATION CHANGED?!?!?");
}
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final currentUser = context.watch<LocalUserAccount>().paperlessUser; final currentUser = context.watch<LocalUserAccount>().paperlessUser;
return PopWithUnsavedChanges( return BlocConsumer<DocumentEditCubit, DocumentEditState>(
hasChangesPredicate: () => _formKey.currentState?.isDirty ?? false, listenWhen: (previous, current) =>
child: BlocBuilder<DocumentEditCubit, DocumentEditState>( previous.document.content != current.document.content,
builder: (context, state) { listener: (context, state) {
final filteredSuggestions = state.suggestions?.documentDifference( final contentField = _formKey.currentState?.fields[fkContent];
context.read<DocumentEditCubit>().state.document); if (contentField == null) {
return DefaultTabController( return;
}
if (contentField.isDirty) {
showDialog(
context: context,
builder: (context) => AlertDialog(
//TODO: INTL
title: Text("Content has changed!"),
content: Text(
"The content of this document has changed. This can happen if the full content was not yet loaded. By accepting the incoming changes, your changes will be overwritten and therefore lost! Do you want to discard your changes in favor of the full content?",
),
actions: [
DialogCancelButton(),
ElevatedButton(
onPressed: () {
contentField.didChange(state.document.content);
Navigator.of(context).pop();
},
child: Text(S.of(context)!.discard),
),
],
),
);
} else {
contentField.didChange(state.document.content);
}
},
builder: (context, state) {
final filteredSuggestions = state.suggestions;
return PopWithUnsavedChanges(
hasChangesPredicate: () {
final fkState = _formKey.currentState;
if (fkState == null) {
return false;
}
final doc = state.document;
final (
title,
correspondent,
documentType,
storagePath,
tags,
createdAt,
content
) = _currentValues;
final isContentTouched =
_formKey.currentState?.fields[fkContent]?.isDirty ?? false;
return doc.title != title ||
doc.correspondent != correspondent ||
doc.documentType != documentType ||
doc.storagePath != storagePath ||
!const UnorderedIterableEquality().equals(doc.tags, tags) ||
doc.created != createdAt ||
(doc.content != content && isContentTouched);
},
child: DefaultTabController(
length: 2, length: 2,
child: Scaffold( child: Scaffold(
resizeToAvoidBottomInset: false, resizeToAvoidBottomInset: false,
@@ -95,13 +157,11 @@ class _DocumentEditPageState extends State<DocumentEditPage> {
LabelFormField<Correspondent>( LabelFormField<Correspondent>(
showAnyAssignedOption: false, showAnyAssignedOption: false,
showNotAssignedOption: false, showNotAssignedOption: false,
addLabelPageBuilder: (initialValue) => onAddLabel: (currentInput) =>
RepositoryProvider.value( CreateLabelRoute(
value: context.read<LabelRepository>(), LabelType.correspondent,
child: AddCorrespondentPage( name: currentInput,
initialName: initialValue, ).push<Correspondent>(context),
),
),
addLabelText: addLabelText:
S.of(context)!.addCorrespondent, S.of(context)!.addCorrespondent,
labelText: S.of(context)!.correspondent, labelText: S.of(context)!.correspondent,
@@ -121,26 +181,10 @@ class _DocumentEditPageState extends State<DocumentEditPage> {
allowSelectUnassigned: true, allowSelectUnassigned: true,
canCreateNewLabel: canCreateNewLabel:
currentUser.canCreateCorrespondents, currentUser.canCreateCorrespondents,
suggestions:
filteredSuggestions?.correspondents ??
[],
), ),
if (filteredSuggestions
?.hasSuggestedCorrespondents ??
false)
_buildSuggestionsSkeleton<int>(
suggestions:
filteredSuggestions!.correspondents,
itemBuilder: (context, itemData) =>
ActionChip(
label: Text(state
.correspondents[itemData]!.name),
onPressed: () {
_formKey.currentState
?.fields[fkCorrespondent]
?.didChange(
SetIdQueryParameter(id: itemData),
);
},
),
),
], ],
).padded(), ).padded(),
// DocumentType form field // DocumentType form field
@@ -150,13 +194,11 @@ class _DocumentEditPageState extends State<DocumentEditPage> {
LabelFormField<DocumentType>( LabelFormField<DocumentType>(
showAnyAssignedOption: false, showAnyAssignedOption: false,
showNotAssignedOption: false, showNotAssignedOption: false,
addLabelPageBuilder: (currentInput) => onAddLabel: (currentInput) =>
RepositoryProvider.value( CreateLabelRoute(
value: context.read<LabelRepository>(), LabelType.documentType,
child: AddDocumentTypePage( name: currentInput,
initialName: currentInput, ).push<DocumentType>(context),
),
),
canCreateNewLabel: canCreateNewLabel:
currentUser.canCreateDocumentTypes, currentUser.canCreateDocumentTypes,
addLabelText: addLabelText:
@@ -172,24 +214,10 @@ class _DocumentEditPageState extends State<DocumentEditPage> {
prefixIcon: prefixIcon:
const Icon(Icons.description_outlined), const Icon(Icons.description_outlined),
allowSelectUnassigned: true, allowSelectUnassigned: true,
suggestions:
filteredSuggestions?.documentTypes ??
[],
), ),
if (filteredSuggestions
?.hasSuggestedDocumentTypes ??
false)
_buildSuggestionsSkeleton<int>(
suggestions:
filteredSuggestions!.documentTypes,
itemBuilder: (context, itemData) =>
ActionChip(
label: Text(state
.documentTypes[itemData]!.name),
onPressed: () => _formKey.currentState
?.fields[fkDocumentType]
?.didChange(
SetIdQueryParameter(id: itemData),
),
),
),
], ],
).padded(), ).padded(),
// StoragePath form field // StoragePath form field
@@ -199,12 +227,11 @@ class _DocumentEditPageState extends State<DocumentEditPage> {
LabelFormField<StoragePath>( LabelFormField<StoragePath>(
showAnyAssignedOption: false, showAnyAssignedOption: false,
showNotAssignedOption: false, showNotAssignedOption: false,
addLabelPageBuilder: (initialValue) => onAddLabel: (currentInput) =>
RepositoryProvider.value( CreateLabelRoute(
value: context.read<LabelRepository>(), LabelType.storagePath,
child: AddStoragePathPage( name: currentInput,
initialName: initialValue), ).push<StoragePath>(context),
),
canCreateNewLabel: canCreateNewLabel:
currentUser.canCreateStoragePaths, currentUser.canCreateStoragePaths,
addLabelText: S.of(context)!.addStoragePath, addLabelText: S.of(context)!.addStoragePath,
@@ -230,6 +257,7 @@ class _DocumentEditPageState extends State<DocumentEditPage> {
allowOnlySelection: true, allowOnlySelection: true,
allowCreation: true, allowCreation: true,
allowExclude: false, allowExclude: false,
suggestions: filteredSuggestions?.tags ?? [],
initialValue: IdsTagsQuery( initialValue: IdsTagsQuery(
include: state.document.tags.toList(), include: state.document.tags.toList(),
), ),
@@ -239,40 +267,7 @@ class _DocumentEditPageState extends State<DocumentEditPage> {
.difference(state.document.tags.toSet()) .difference(state.document.tags.toSet())
.isNotEmpty ?? .isNotEmpty ??
false) false)
_buildSuggestionsSkeleton<int>( const SizedBox(height: 64),
suggestions:
(filteredSuggestions?.tags.toSet() ?? {}),
itemBuilder: (context, itemData) {
final tag = state.tags[itemData]!;
return ActionChip(
label: Text(
tag.name,
style: TextStyle(color: tag.textColor),
),
backgroundColor: tag.color,
onPressed: () {
final currentTags = _formKey.currentState
?.fields[fkTags]?.value as TagsQuery;
_formKey.currentState?.fields[fkTags]
?.didChange(
switch (currentTags) {
IdsTagsQuery(
include: var i,
exclude: var e
) =>
IdsTagsQuery(
include: [...i, itemData],
exclude: e,
),
_ => IdsTagsQuery(include: [itemData])
},
);
},
);
},
),
// Prevent tags from being hidden by fab
const SizedBox(height: 64),
], ],
), ),
SingleChildScrollView( SingleChildScrollView(
@@ -295,45 +290,104 @@ class _DocumentEditPageState extends State<DocumentEditPage> {
), ),
), ),
)), )),
); ),
}, );
), },
);
}
bool _isFieldDirty(DocumentModel document) {
final fkState = _formKey.currentState;
if (fkState == null) {
return false;
}
fkState.save();
final (
title,
correspondent,
documentType,
storagePath,
tags,
createdAt,
content
) = _currentValues;
return document.title != title ||
document.correspondent != correspondent ||
document.documentType != documentType ||
document.storagePath != storagePath ||
const UnorderedIterableEquality().equals(document.tags, tags) ||
document.created != createdAt ||
document.content != content;
}
(
String? title,
int? correspondent,
int? documentType,
int? storagePath,
List<int>? tags,
DateTime? createdAt,
String? content,
) get _currentValues {
final fkState = _formKey.currentState!;
final correspondentParam =
fkState.getRawValue<IdQueryParameter?>(fkCorrespondent);
final documentTypeParam =
fkState.getRawValue<IdQueryParameter?>(fkDocumentType);
final storagePathParam =
fkState.getRawValue<IdQueryParameter?>(fkStoragePath);
final tagsParam = fkState.getRawValue<TagsQuery?>(fkTags);
final title = fkState.getRawValue<String?>(fkTitle);
final created = fkState.getRawValue<DateTime?>(fkCreatedDate);
final correspondent = switch (correspondentParam) {
SetIdQueryParameter(id: var id) => id,
_ => null,
};
final documentType = switch (documentTypeParam) {
SetIdQueryParameter(id: var id) => id,
_ => null,
};
final storagePath = switch (storagePathParam) {
SetIdQueryParameter(id: var id) => id,
_ => null,
};
final tags = switch (tagsParam) {
IdsTagsQuery(include: var i) => i,
_ => null,
};
final content = fkState.getRawValue<String?>(fkContent);
return (
title,
correspondent,
documentType,
storagePath,
tags,
created,
content
); );
} }
Future<void> _onSubmit(DocumentModel document) async { Future<void> _onSubmit(DocumentModel document) async {
if (_formKey.currentState?.saveAndValidate() ?? false) { if (_formKey.currentState?.saveAndValidate() ?? false) {
final values = _formKey.currentState!.value; final (
title,
final correspondentParam = values[fkCorrespondent] as IdQueryParameter?; correspondent,
final documentTypeParam = values[fkDocumentType] as IdQueryParameter?; documentType,
final storagePathParam = values[fkStoragePath] as IdQueryParameter?; storagePath,
final tagsParam = values[fkTags] as TagsQuery?; tags,
createdAt,
final correspondent = switch (correspondentParam) { content
SetIdQueryParameter(id: var id) => id, ) = _currentValues;
_ => null,
};
final documentType = switch (documentTypeParam) {
SetIdQueryParameter(id: var id) => id,
_ => null,
};
final storagePath = switch (storagePathParam) {
SetIdQueryParameter(id: var id) => id,
_ => null,
};
final tags = switch (tagsParam) {
IdsTagsQuery(include: var i) => i,
_ => null,
};
var mergedDocument = document.copyWith( var mergedDocument = document.copyWith(
title: values[fkTitle], title: title,
created: values[fkCreatedDate], created: createdAt,
correspondent: () => correspondent, correspondent: () => correspondent,
documentType: () => documentType, documentType: () => documentType,
storagePath: () => storagePath, storagePath: () => storagePath,
tags: tags, tags: tags,
content: values[fkContent], content: content,
); );
try { try {

View File

@@ -24,6 +24,8 @@ import 'package:paperless_mobile/features/labels/view/widgets/label_form_field.d
import 'package:paperless_mobile/features/sharing/view/widgets/file_thumbnail.dart'; import 'package:paperless_mobile/features/sharing/view/widgets/file_thumbnail.dart';
import 'package:paperless_mobile/generated/l10n/app_localizations.dart'; import 'package:paperless_mobile/generated/l10n/app_localizations.dart';
import 'package:paperless_mobile/helpers/message_helpers.dart'; import 'package:paperless_mobile/helpers/message_helpers.dart';
import 'package:paperless_mobile/routes/typed/branches/labels_route.dart';
import 'package:paperless_mobile/routes/typed/shells/authenticated_route.dart';
import 'package:provider/provider.dart'; import 'package:provider/provider.dart';
class DocumentUploadResult { class DocumentUploadResult {
@@ -251,19 +253,10 @@ class _DocumentUploadPreparationPageState
LabelFormField<Correspondent>( LabelFormField<Correspondent>(
showAnyAssignedOption: false, showAnyAssignedOption: false,
showNotAssignedOption: false, showNotAssignedOption: false,
addLabelPageBuilder: (initialName) => onAddLabel: (initialName) => CreateLabelRoute(
MultiProvider( LabelType.correspondent,
providers: [ name: initialName,
Provider.value( ).push<Correspondent>(context),
value: context.read<LabelRepository>(),
),
Provider.value(
value: context.read<ApiVersion>(),
)
],
child: AddCorrespondentPage(
initialName: initialName),
),
addLabelText: S.of(context)!.addCorrespondent, addLabelText: S.of(context)!.addCorrespondent,
labelText: S.of(context)!.correspondent + " *", labelText: S.of(context)!.correspondent + " *",
name: DocumentModel.correspondentKey, name: DocumentModel.correspondentKey,
@@ -283,19 +276,10 @@ class _DocumentUploadPreparationPageState
LabelFormField<DocumentType>( LabelFormField<DocumentType>(
showAnyAssignedOption: false, showAnyAssignedOption: false,
showNotAssignedOption: false, showNotAssignedOption: false,
addLabelPageBuilder: (initialName) => onAddLabel: (initialName) => CreateLabelRoute(
MultiProvider( LabelType.documentType,
providers: [ name: initialName,
Provider.value( ).push<DocumentType>(context),
value: context.read<LabelRepository>(),
),
Provider.value(
value: context.read<ApiVersion>(),
)
],
child: AddDocumentTypePage(
initialName: initialName),
),
addLabelText: S.of(context)!.addDocumentType, addLabelText: S.of(context)!.addDocumentType,
labelText: S.of(context)!.documentType + " *", labelText: S.of(context)!.documentType + " *",
name: DocumentModel.documentTypeKey, name: DocumentModel.documentTypeKey,

View File

@@ -1,8 +1,11 @@
import 'package:collection/collection.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:paperless_api/paperless_api.dart'; import 'package:paperless_api/paperless_api.dart';
import 'package:paperless_mobile/extensions/flutter_extensions.dart'; import 'package:paperless_mobile/extensions/flutter_extensions.dart';
import 'package:paperless_mobile/features/edit_label/view/impl/add_tag_page.dart'; import 'package:paperless_mobile/features/edit_label/view/impl/add_tag_page.dart';
import 'package:paperless_mobile/generated/l10n/app_localizations.dart'; import 'package:paperless_mobile/generated/l10n/app_localizations.dart';
import 'package:paperless_mobile/routes/typed/branches/labels_route.dart';
import 'package:paperless_mobile/routes/typed/shells/authenticated_route.dart';
class FullscreenTagsForm extends StatefulWidget { class FullscreenTagsForm extends StatefulWidget {
final TagsQuery? initialValue; final TagsQuery? initialValue;
@@ -46,7 +49,7 @@ class _FullscreenTagsFormState extends State<FullscreenTagsForm> {
final value = widget.initialValue; final value = widget.initialValue;
if (value is IdsTagsQuery) { if (value is IdsTagsQuery) {
_include = value.include.toList(); _include = value.include.toList();
_exclude = value.include.toList(); _exclude = value.exclude.toList();
} else if (value is AnyAssignedTagsQuery) { } else if (value is AnyAssignedTagsQuery) {
_include = value.tagIds.toList(); _include = value.tagIds.toList();
_anyAssigned = true; _anyAssigned = true;
@@ -116,16 +119,26 @@ class _FullscreenTagsFormState extends State<FullscreenTagsForm> {
icon: const Icon(Icons.done), icon: const Icon(Icons.done),
onPressed: () { onPressed: () {
if (widget.allowOnlySelection) { if (widget.allowOnlySelection) {
widget.onSubmit(returnValue: IdsTagsQuery(include: _include)); widget.onSubmit(
returnValue: IdsTagsQuery(
include:
_include.sortedBy((id) => widget.options[id]!.name),
),
);
return; return;
} }
late final TagsQuery query; late final TagsQuery query;
if (_notAssigned) { if (_notAssigned) {
query = const NotAssignedTagsQuery(); query = const NotAssignedTagsQuery();
} else if (_anyAssigned) { } else if (_anyAssigned) {
query = AnyAssignedTagsQuery(tagIds: _include); query = AnyAssignedTagsQuery(
tagIds: _include.sortedBy((id) => widget.options[id]!.name),
);
} else { } else {
query = IdsTagsQuery(include: _include, exclude: _exclude); query = IdsTagsQuery(
include: _include.sortedBy((id) => widget.options[id]!.name),
exclude: _exclude.sortedBy((id) => widget.options[id]!.name),
);
} }
widget.onSubmit(returnValue: query); widget.onSubmit(returnValue: query);
}, },
@@ -191,13 +204,9 @@ class _FullscreenTagsFormState extends State<FullscreenTagsForm> {
} }
void _onAddTag() async { void _onAddTag() async {
final createdTag = await Navigator.of(context).push<Tag?>( final createdTag =
MaterialPageRoute( await CreateLabelRoute(LabelType.tag, name: _textEditingController.text)
builder: (context) => AddTagPage( .push<Tag>(context);
initialName: _textEditingController.text,
),
),
);
_textEditingController.clear(); _textEditingController.clear();
if (createdTag != null) { if (createdTag != null) {
setState(() { setState(() {
@@ -308,21 +317,21 @@ class SelectableTagWidget extends StatelessWidget {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final includeColor = Colors.green.withOpacity(0.3);
final excludeColor = Colors.red.withOpacity(0.3);
return ListTile( return ListTile(
title: Text(tag.name), title: Text(tag.name),
trailing: excluded trailing: Text(S.of(context)!.documentsAssigned(tag.documentCount ?? 0)),
? const Icon(Icons.close)
: (selected ? const Icon(Icons.done) : null),
leading: CircleAvatar( leading: CircleAvatar(
backgroundColor: tag.color, backgroundColor: tag.color,
child: (tag.isInboxTag) child: tag.isInboxTag ? Icon(Icons.inbox, color: tag.textColor) : null,
? Icon(
Icons.inbox,
color: tag.textColor,
)
: null,
), ),
onTap: onTap, onTap: onTap,
tileColor: excluded
? excludeColor
: selected
? includeColor
: null,
); );
} }
} }

View File

@@ -6,6 +6,7 @@ import 'package:flutter_form_builder/flutter_form_builder.dart';
import 'package:paperless_api/paperless_api.dart'; import 'package:paperless_api/paperless_api.dart';
import 'package:paperless_mobile/core/database/tables/local_user_account.dart'; import 'package:paperless_mobile/core/database/tables/local_user_account.dart';
import 'package:paperless_mobile/core/workarounds/colored_chip.dart'; import 'package:paperless_mobile/core/workarounds/colored_chip.dart';
import 'package:paperless_mobile/extensions/flutter_extensions.dart';
import 'package:paperless_mobile/features/labels/tags/view/widgets/fullscreen_tags_form.dart'; import 'package:paperless_mobile/features/labels/tags/view/widgets/fullscreen_tags_form.dart';
import 'package:paperless_mobile/generated/l10n/app_localizations.dart'; import 'package:paperless_mobile/generated/l10n/app_localizations.dart';
@@ -16,6 +17,7 @@ class TagsFormField extends StatelessWidget {
final bool allowOnlySelection; final bool allowOnlySelection;
final bool allowCreation; final bool allowCreation;
final bool allowExclude; final bool allowExclude;
final Iterable<int> suggestions;
const TagsFormField({ const TagsFormField({
super.key, super.key,
@@ -25,63 +27,120 @@ class TagsFormField extends StatelessWidget {
required this.allowOnlySelection, required this.allowOnlySelection,
required this.allowCreation, required this.allowCreation,
required this.allowExclude, required this.allowExclude,
this.suggestions = const [],
}); });
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final enabled = options.values.isNotEmpty || allowCreation;
return FormBuilderField<TagsQuery?>( return FormBuilderField<TagsQuery?>(
initialValue: initialValue, initialValue: initialValue,
enabled: enabled,
builder: (field) { builder: (field) {
final values = _generateOptions(context, field.value, field).toList(); final values = _generateOptions(context, field.value, field).toList();
final isEmpty = (field.value is IdsTagsQuery && final isEmpty = (field.value is IdsTagsQuery &&
(field.value as IdsTagsQuery).include.isEmpty) || (field.value as IdsTagsQuery).include.isEmpty) ||
field.value == null; field.value == null;
bool anyAssigned = field.value is AnyAssignedTagsQuery; bool anyAssigned = field.value is AnyAssignedTagsQuery;
return OpenContainer<TagsQuery>(
middleColor: Theme.of(context).colorScheme.background, final displayedSuggestions = switch (field.value) {
closedColor: Theme.of(context).colorScheme.background, IdsTagsQuery(include: var include) =>
openColor: Theme.of(context).colorScheme.background, suggestions.toSet().difference(include.toSet()).toList(),
closedShape: InputBorder.none, _ => <int>[],
openElevation: 0, };
closedElevation: 0, return Column(
closedBuilder: (context, openForm) => Container( children: [
margin: const EdgeInsets.only(top: 6), OpenContainer<TagsQuery>(
child: GestureDetector( middleColor: Theme.of(context).colorScheme.background,
onTap: openForm, closedColor: Theme.of(context).colorScheme.background,
child: InputDecorator( openColor: Theme.of(context).colorScheme.background,
isEmpty: isEmpty, closedShape: InputBorder.none,
decoration: InputDecoration( openElevation: 0,
contentPadding: const EdgeInsets.all(12), closedElevation: 0,
labelText: tappable: enabled,
'${S.of(context)!.tags}${anyAssigned ? ' (${S.of(context)!.anyAssigned})' : ''}', closedBuilder: (context, openForm) => Container(
prefixIcon: const Icon(Icons.label_outline), margin: const EdgeInsets.only(top: 6),
), child: GestureDetector(
child: SizedBox( onTap: openForm,
height: 32, child: InputDecorator(
child: ListView.separated( isEmpty: isEmpty,
scrollDirection: Axis.horizontal, decoration: InputDecoration(
separatorBuilder: (context, index) => contentPadding: const EdgeInsets.all(12),
const SizedBox(width: 4), labelText:
itemBuilder: (context, index) => values[index], '${S.of(context)!.tags}${anyAssigned ? ' (${S.of(context)!.anyAssigned})' : ''}',
itemCount: values.length, prefixIcon: const Icon(Icons.label_outline),
enabled: enabled,
),
child: SizedBox(
height: 32,
child: ListView.separated(
scrollDirection: Axis.horizontal,
separatorBuilder: (context, index) =>
const SizedBox(width: 4),
itemBuilder: (context, index) => values[index],
itemCount: values.length,
),
), ),
), ),
), ),
)), ),
openBuilder: (context, closeForm) => FullscreenTagsForm( openBuilder: (context, closeForm) => FullscreenTagsForm(
options: options, options: options,
onSubmit: closeForm, onSubmit: closeForm,
initialValue: field.value, initialValue: field.value,
allowOnlySelection: allowOnlySelection, allowOnlySelection: allowOnlySelection,
allowCreation: allowCreation && allowCreation: allowCreation &&
context.watch<LocalUserAccount>().paperlessUser.canCreateTags, context
allowExclude: allowExclude, .watch<LocalUserAccount>()
), .paperlessUser
onClosed: (data) { .canCreateTags,
if (data != null) { allowExclude: allowExclude,
field.didChange(data); ),
} onClosed: (data) {
}, if (data != null) {
field.didChange(data);
}
},
),
if (displayedSuggestions.isNotEmpty)
Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
S.of(context)!.suggestions,
style: Theme.of(context).textTheme.bodySmall,
),
SizedBox(
height: kMinInteractiveDimension,
child: ListView.separated(
scrollDirection: Axis.horizontal,
itemCount: displayedSuggestions.length,
itemBuilder: (context, index) {
final suggestion =
options[displayedSuggestions.elementAt(index)]!;
return ColoredChipWrapper(
child: ActionChip(
label: Text(suggestion.name),
onPressed: () {
field.didChange(switch (field.value) {
IdsTagsQuery(include: var include) =>
IdsTagsQuery(
include: [...include, suggestion.id!],
),
_ => IdsTagsQuery(include: [suggestion.id!]),
});
},
),
);
},
separatorBuilder: (BuildContext context, int index) =>
const SizedBox(width: 4.0),
),
),
],
).padded(),
],
); );
}, },
name: name, name: name,

View File

@@ -5,7 +5,8 @@ import 'package:paperless_mobile/extensions/flutter_extensions.dart';
import 'package:paperless_mobile/generated/l10n/app_localizations.dart'; import 'package:paperless_mobile/generated/l10n/app_localizations.dart';
class FullscreenLabelForm<T extends Label> extends StatefulWidget { class FullscreenLabelForm<T extends Label> extends StatefulWidget {
final IdQueryParameter? initialValue; /// If null, this will resolve to [UnsetIdQueryParameter].
final IdQueryParameter initialValue;
final Map<int, T> options; final Map<int, T> options;
final Future<T?> Function(String? initialName)? onCreateNewLabel; final Future<T?> Function(String? initialName)? onCreateNewLabel;
@@ -20,7 +21,7 @@ class FullscreenLabelForm<T extends Label> extends StatefulWidget {
FullscreenLabelForm({ FullscreenLabelForm({
super.key, super.key,
this.initialValue, this.initialValue = const UnsetIdQueryParameter(),
required this.options, required this.options,
required this.onCreateNewLabel, required this.onCreateNewLabel,
this.showNotAssignedOption = true, this.showNotAssignedOption = true,
@@ -31,12 +32,8 @@ class FullscreenLabelForm<T extends Label> extends StatefulWidget {
this.autofocus = true, this.autofocus = true,
this.allowSelectUnassigned = true, this.allowSelectUnassigned = true,
required this.canCreateNewLabel, required this.canCreateNewLabel,
}) : assert( }) : assert(!(initialValue.isOnlyAssigned) || showAnyAssignedOption),
!(initialValue?.isOnlyAssigned ?? false) || showAnyAssignedOption, assert(!(initialValue.isOnlyNotAssigned) || showNotAssignedOption),
),
assert(
!(initialValue?.isOnlyNotAssigned ?? false) || showNotAssignedOption,
),
assert((addNewLabelText != null) == (onCreateNewLabel != null)); assert((addNewLabelText != null) == (onCreateNewLabel != null));
@override @override
@@ -52,9 +49,9 @@ class _FullscreenLabelFormState<T extends Label>
@override @override
void initState() { void initState() {
super.initState(); super.initState();
_textEditingController.addListener(() => setState(() { _textEditingController.addListener(() {
_showClearIcon = _textEditingController.text.isNotEmpty; setState(() => _showClearIcon = _textEditingController.text.isNotEmpty);
})); });
if (widget.autofocus) { if (widget.autofocus) {
WidgetsBinding.instance.addPostFrameCallback((timeStamp) { WidgetsBinding.instance.addPostFrameCallback((timeStamp) {
//Delay keyboard popup to ensure open animation is finished before. //Delay keyboard popup to ensure open animation is finished before.
@@ -130,36 +127,42 @@ class _FullscreenLabelFormState<T extends Label>
child: const Icon(Icons.add), child: const Icon(Icons.add),
) )
: null, : null,
body: Builder( body: Builder(builder: (context) {
builder: (context) { return Column(
return Column( children: [
children: [ Expanded(
Expanded( child: ListView.builder(
child: ListView.builder( padding: EdgeInsets.zero,
padding: EdgeInsets.zero, shrinkWrap: true,
shrinkWrap: true, itemCount: options.length,
itemCount: options.length, itemBuilder: (BuildContext context, int index) {
itemBuilder: (BuildContext context, int index) { final option = options.elementAt(index);
final option = options.elementAt(index); final shouldHighlight = switch (option) {
final highlight = NotAssignedIdQueryParameter() => true,
AutocompleteHighlightedOption.of(context) == index; AnyAssignedIdQueryParameter() => true,
if (highlight) { SetIdQueryParameter(id: var id) =>
SchedulerBinding.instance (widget.options[id]?.documentCount ?? 0) > 0,
.addPostFrameCallback((Duration timeStamp) { _ => false,
Scrollable.ensureVisible( };
context, final highlight =
alignment: 0, AutocompleteHighlightedOption.of(context) == index;
); if (highlight && shouldHighlight) {
}); SchedulerBinding.instance
} .addPostFrameCallback((Duration timeStamp) {
return _buildOptionWidget(option, highlight); Scrollable.ensureVisible(
}, context,
), alignment: 0,
);
});
}
return _buildOptionWidget(
option, highlight && shouldHighlight);
},
), ),
], ),
); ],
}, );
), }),
); );
} }
@@ -179,8 +182,12 @@ class _FullscreenLabelFormState<T extends Label>
Iterable<IdQueryParameter> _filterOptionsByQuery(String query) sync* { Iterable<IdQueryParameter> _filterOptionsByQuery(String query) sync* {
final normalizedQuery = query.trim().toLowerCase(); final normalizedQuery = query.trim().toLowerCase();
if (normalizedQuery.isEmpty) { if (normalizedQuery.isEmpty) {
if (widget.initialValue == null) { if ((widget.initialValue.isUnset)) {
// If nothing is selected yet, show all options first. if (widget.options.isEmpty) {
yield const UnsetIdQueryParameter();
return;
}
// If nothing is selected yet (==> UnsetIdQueryParameter), show all options first.
for (final option in widget.options.values) { for (final option in widget.options.values) {
yield SetIdQueryParameter(id: option.id!); yield SetIdQueryParameter(id: option.id!);
} }
@@ -243,12 +250,17 @@ class _FullscreenLabelFormState<T extends Label>
AnyAssignedIdQueryParameter() => S.of(context)!.anyAssigned, AnyAssignedIdQueryParameter() => S.of(context)!.anyAssigned,
SetIdQueryParameter(id: var id) => SetIdQueryParameter(id: var id) =>
widget.options[id]?.name ?? S.of(context)!.startTyping, widget.options[id]?.name ?? S.of(context)!.startTyping,
_ => null,
}; };
} }
Widget _buildOptionWidget(IdQueryParameter option, bool highlight) { Widget _buildOptionWidget(IdQueryParameter option, bool highlight) {
void onTap() => widget.onSubmit(returnValue: option); void onTap() => widget.onSubmit(returnValue: option);
final hasNoAssignedDocumentsTextStyle = Theme.of(context)
.textTheme
.labelMedium
?.apply(color: Theme.of(context).disabledColor);
final hasAssignedDocumentsTextStyle =
Theme.of(context).textTheme.labelMedium;
return switch (option) { return switch (option) {
NotAssignedIdQueryParameter() => ListTile( NotAssignedIdQueryParameter() => ListTile(
@@ -266,11 +278,20 @@ class _FullscreenLabelFormState<T extends Label>
SetIdQueryParameter(id: var id) => ListTile( SetIdQueryParameter(id: var id) => ListTile(
selected: highlight, selected: highlight,
selectedTileColor: Theme.of(context).focusColor, selectedTileColor: Theme.of(context).focusColor,
title: Text(widget.options[id]!.name), title: Text(
widget.options[id]!.name,
maxLines: 2,
overflow: TextOverflow.ellipsis,
),
onTap: onTap, onTap: onTap,
enabled: widget.allowSelectUnassigned trailing: Text(
? true S
: widget.options[id]!.documentCount != 0, .of(context)!
.documentsAssigned(widget.options[id]!.documentCount ?? 0),
style: (widget.options[id]!.documentCount ?? 0) > 0
? hasAssignedDocumentsTextStyle
: hasNoAssignedDocumentsTextStyle,
),
), ),
UnsetIdQueryParameter() => Center( UnsetIdQueryParameter() => Center(
child: Column( child: Column(

View File

@@ -19,11 +19,11 @@ class LabelFormField<T extends Label> extends StatelessWidget {
final String name; final String name;
final String labelText; final String labelText;
final FormFieldValidator? validator; final FormFieldValidator? validator;
final Widget Function(String? initialName)? addLabelPageBuilder; final Future<T?> Function(String? initialName)? onAddLabel;
final void Function(IdQueryParameter?)? onChanged; final void Function(IdQueryParameter?)? onChanged;
final bool showNotAssignedOption; final bool showNotAssignedOption;
final bool showAnyAssignedOption; final bool showAnyAssignedOption;
final List<T> suggestions; final Iterable<int> suggestions;
final String? addLabelText; final String? addLabelText;
final bool allowSelectUnassigned; final bool allowSelectUnassigned;
final bool canCreateNewLabel; final bool canCreateNewLabel;
@@ -36,7 +36,7 @@ class LabelFormField<T extends Label> extends StatelessWidget {
required this.prefixIcon, required this.prefixIcon,
this.initialValue, this.initialValue,
this.validator, this.validator,
this.addLabelPageBuilder, this.onAddLabel,
this.onChanged, this.onChanged,
this.showNotAssignedOption = true, this.showNotAssignedOption = true,
this.showAnyAssignedOption = true, this.showAnyAssignedOption = true,
@@ -58,21 +58,21 @@ class LabelFormField<T extends Label> extends StatelessWidget {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final isEnabled = options.values.any((e) => (e.documentCount ?? 0) > 0) || final enabled = options.values.isNotEmpty || onAddLabel != null;
addLabelPageBuilder != null;
return FormBuilderField<IdQueryParameter>( return FormBuilderField<IdQueryParameter>(
name: name, name: name,
initialValue: initialValue, initialValue: initialValue,
onChanged: onChanged, onChanged: onChanged,
enabled: isEnabled, enabled: enabled,
builder: (field) { builder: (field) {
final controller = TextEditingController( final controller = TextEditingController(
text: _buildText(context, field.value), text: _buildText(context, field.value),
); );
final displayedSuggestions = suggestions final displayedSuggestions = suggestions
.whereNot( .whereNot(
(e) => (id) =>
e.id == id ==
switch (field.value) { switch (field.value) {
SetIdQueryParameter(id: var id) => id, SetIdQueryParameter(id: var id) => id,
_ => -1, _ => -1,
@@ -89,13 +89,14 @@ class LabelFormField<T extends Label> extends StatelessWidget {
closedShape: InputBorder.none, closedShape: InputBorder.none,
openElevation: 0, openElevation: 0,
closedElevation: 0, closedElevation: 0,
tappable: enabled,
closedBuilder: (context, openForm) => Container( closedBuilder: (context, openForm) => Container(
margin: const EdgeInsets.only(top: 6), margin: const EdgeInsets.only(top: 6),
child: TextField( child: TextField(
controller: controller, controller: controller,
onTap: openForm, onTap: openForm,
readOnly: true, readOnly: true,
enabled: isEnabled, enabled: enabled,
decoration: InputDecoration( decoration: InputDecoration(
prefixIcon: prefixIcon, prefixIcon: prefixIcon,
labelText: labelText, labelText: labelText,
@@ -114,19 +115,10 @@ class LabelFormField<T extends Label> extends StatelessWidget {
canCreateNewLabel: canCreateNewLabel, canCreateNewLabel: canCreateNewLabel,
addNewLabelText: addLabelText, addNewLabelText: addLabelText,
leadingIcon: prefixIcon, leadingIcon: prefixIcon,
onCreateNewLabel: addLabelPageBuilder != null onCreateNewLabel: onAddLabel,
? (initialName) {
return Navigator.of(context).push<T>(
MaterialPageRoute(
builder: (context) =>
addLabelPageBuilder!(initialName),
),
);
}
: null,
options: options, options: options,
onSubmit: closeForm, onSubmit: closeForm,
initialValue: field.value, initialValue: field.value ?? const UnsetIdQueryParameter(),
showAnyAssignedOption: showAnyAssignedOption, showAnyAssignedOption: showAnyAssignedOption,
showNotAssignedOption: showNotAssignedOption, showNotAssignedOption: showNotAssignedOption,
), ),
@@ -151,7 +143,8 @@ class LabelFormField<T extends Label> extends StatelessWidget {
itemCount: displayedSuggestions.length, itemCount: displayedSuggestions.length,
itemBuilder: (context, index) { itemBuilder: (context, index) {
final suggestion = final suggestion =
displayedSuggestions.elementAt(index); options[displayedSuggestions.elementAt(index)]!;
return ColoredChipWrapper( return ColoredChipWrapper(
child: ActionChip( child: ActionChip(
label: Text(suggestion.name), label: Text(suggestion.name),

View File

@@ -996,5 +996,13 @@
"restoringSession": "Restoring session...", "restoringSession": "Restoring session...",
"@restoringSession": { "@restoringSession": {
"description": "Message shown when the user opens the app and the previous user is tried to be authenticated and logged in" "description": "Message shown when the user opens the app and the previous user is tried to be authenticated and logged in"
},
"documentsAssigned": "{count, plural, zero{No documents} one{1 document} other{{count} documents}}",
"@documentsAssigned": {
"description": "Text shown with a correspondent, document type etc. to indicate the number of documents this filter will maximally yield."
},
"discardChangesWarning": "You have unsaved changes. By continuing, all changes will be lost. Do you want to discard these changes?",
"@discardChangesWarning": {
"description": "Warning message shown when the user tries to close a route without saving the changes."
} }
} }

View File

@@ -996,5 +996,13 @@
"restoringSession": "Restoring session...", "restoringSession": "Restoring session...",
"@restoringSession": { "@restoringSession": {
"description": "Message shown when the user opens the app and the previous user is tried to be authenticated and logged in" "description": "Message shown when the user opens the app and the previous user is tried to be authenticated and logged in"
},
"documentsAssigned": "{count, plural, zero{No documents} one{1 document} other{{count} documents}}",
"@documentsAssigned": {
"description": "Text shown with a correspondent, document type etc. to indicate the number of documents this filter will maximally yield."
},
"discardChangesWarning": "You have unsaved changes. By continuing, all changes will be lost. Do you want to discard these changes?",
"@discardChangesWarning": {
"description": "Warning message shown when the user tries to close a route without saving the changes."
} }
} }

View File

@@ -996,5 +996,13 @@
"restoringSession": "Sitzung wird wiederhergestellt...", "restoringSession": "Sitzung wird wiederhergestellt...",
"@restoringSession": { "@restoringSession": {
"description": "Message shown when the user opens the app and the previous user is tried to be authenticated and logged in" "description": "Message shown when the user opens the app and the previous user is tried to be authenticated and logged in"
},
"documentsAssigned": "{count, plural, zero{Keine Dokumente} one{1 Dokument} other{{count} Dokumente}}",
"@documentsAssigned": {
"description": "Text shown with a correspondent, document type etc. to indicate the number of documents this filter will maximally yield."
},
"discardChangesWarning": "Du hast ungespeicherte Änderungen. Diese gehen verloren, falls du fortfährst. Möchtest du die Änderungen verwerfen?",
"@discardChangesWarning": {
"description": "Warning message shown when the user tries to close a route without saving the changes."
} }
} }

View File

@@ -348,49 +348,49 @@
"@after": {}, "@after": {},
"before": "Before", "before": "Before",
"@before": {}, "@before": {},
"days": "{count, plural, one{day} other{days}}", "days": "{count, plural, zero{days} one{day} other{days}}",
"@days": { "@days": {
"placeholders": { "placeholders": {
"count": {} "count": {}
} }
}, },
"lastNDays": "{count, plural, one{Yesterday} other{Last {count} days}}", "lastNDays": "{count, plural, zero{} one{Yesterday} other{Last {count} days}}",
"@lastNDays": { "@lastNDays": {
"placeholders": { "placeholders": {
"count": {} "count": {}
} }
}, },
"lastNMonths": "{count, plural, one{Last month} other{Last {count} months}}", "lastNMonths": "{count, plural, zero{} one{Last month} other{Last {count} months}}",
"@lastNMonths": { "@lastNMonths": {
"placeholders": { "placeholders": {
"count": {} "count": {}
} }
}, },
"lastNWeeks": "{count, plural, one{Last week} other{Last {count} weeks}}", "lastNWeeks": "{count, plural, zero{} one{Last week} other{Last {count} weeks}}",
"@lastNWeeks": { "@lastNWeeks": {
"placeholders": { "placeholders": {
"count": {} "count": {}
} }
}, },
"lastNYears": "{count, plural, one{Last year} other{Last {count} years}}", "lastNYears": "{count, plural, zero{} one{Last year} other{Last {count} years}}",
"@lastNYears": { "@lastNYears": {
"placeholders": { "placeholders": {
"count": {} "count": {}
} }
}, },
"months": "{count, plural, one{month} other{months}}", "months": "{count, plural, zero{} one{month} other{months}}",
"@months": { "@months": {
"placeholders": { "placeholders": {
"count": {} "count": {}
} }
}, },
"weeks": "{count, plural, one{week} other{weeks}}", "weeks": "{count, plural, zero{} one{week} other{weeks}}",
"@weeks": { "@weeks": {
"placeholders": { "placeholders": {
"count": {} "count": {}
} }
}, },
"years": "{count, plural, one{year} other{years}}", "years": "{count, plural, zero{} one{year} other{years}}",
"@years": { "@years": {
"placeholders": { "placeholders": {
"count": {} "count": {}
@@ -588,7 +588,7 @@
"@createsASavedViewBasedOnTheCurrentFilterCriteria": {}, "@createsASavedViewBasedOnTheCurrentFilterCriteria": {},
"createViewsToQuicklyFilterYourDocuments": "Create views to quickly filter your documents.", "createViewsToQuicklyFilterYourDocuments": "Create views to quickly filter your documents.",
"@createViewsToQuicklyFilterYourDocuments": {}, "@createViewsToQuicklyFilterYourDocuments": {},
"nFiltersSet": "{count, plural, one{{count} filter set} other{{count} filters set}}", "nFiltersSet": "{count, plural, zero{{count} filters set} one{{count} filter set} other{{count} filters set}}",
"@nFiltersSet": { "@nFiltersSet": {
"placeholders": { "placeholders": {
"count": {} "count": {}
@@ -996,5 +996,13 @@
"restoringSession": "Restoring session...", "restoringSession": "Restoring session...",
"@restoringSession": { "@restoringSession": {
"description": "Message shown when the user opens the app and the previous user is tried to be authenticated and logged in" "description": "Message shown when the user opens the app and the previous user is tried to be authenticated and logged in"
},
"documentsAssigned": "{count, plural, zero{No documents} one{1 document} other{{count} documents}}",
"@documentsAssigned": {
"description": "Text shown with a correspondent, document type etc. to indicate the number of documents this filter will maximally yield."
},
"discardChangesWarning": "You have unsaved changes. By continuing, all changes will be lost. Do you want to discard these changes?",
"@discardChangesWarning": {
"description": "Warning message shown when the user tries to close a route without saving the changes."
} }
} }

View File

@@ -865,7 +865,7 @@
"@missingPermissions": { "@missingPermissions": {
"description": "Message shown in a snackbar when a user without the reequired permissions performs an action." "description": "Message shown in a snackbar when a user without the reequired permissions performs an action."
}, },
"editView": "Edit View", "editView": "Editar Vista",
"@editView": { "@editView": {
"description": "Title of the edit saved view page" "description": "Title of the edit saved view page"
}, },
@@ -873,128 +873,136 @@
"@donate": { "@donate": {
"description": "Label of the in-app donate button" "description": "Label of the in-app donate button"
}, },
"donationDialogContent": "Thank you for considering to support this app! Due to both Google's and Apple's Payment Policies, no links leading to donations may be displayed in-app. Not even linking to the project's repository page appears to be allowed in this context. Therefore, maybe have a look at the 'Donations' section in the project's README. Your support is much appreciated and keeps the development of this app alive. Thanks!", "donationDialogContent": "¡Gracias por querer apoyar esta aplicación!\nDebido a las políticas de pago, tanto de Google como de Apple, no se puede mostrar ningún enlace que lo dirija a las donaciones. En este contexto, ni siquiera es posible enlazar la página del repositorio del proyecto. Por lo tanto, puedes visitar la sección \"Donations\" en el archivo README de este proyecto. Tu apoyo es valorado gratamente y ayuda a mantener con vida el desarrollo de esta aplicación.\n¡Muchas gracias!",
"@donationDialogContent": { "@donationDialogContent": {
"description": "Text displayed in the donation dialog" "description": "Text displayed in the donation dialog"
}, },
"noDocumentsFound": "No documents found.", "noDocumentsFound": "No se han encontrado documentos.",
"@noDocumentsFound": { "@noDocumentsFound": {
"description": "Message shown when no documents were found." "description": "Message shown when no documents were found."
}, },
"couldNotDeleteCorrespondent": "Could not delete correspondent, please try again.", "couldNotDeleteCorrespondent": "No se pudo remover el interlocutor, intente nuevamente.",
"@couldNotDeleteCorrespondent": { "@couldNotDeleteCorrespondent": {
"description": "Message shown in snackbar when a correspondent could not be deleted." "description": "Message shown in snackbar when a correspondent could not be deleted."
}, },
"couldNotDeleteDocumentType": "Could not delete document type, please try again.", "couldNotDeleteDocumentType": "No se pudo remover el tipo de documento, intente nuevamente.",
"@couldNotDeleteDocumentType": { "@couldNotDeleteDocumentType": {
"description": "Message shown when a document type could not be deleted" "description": "Message shown when a document type could not be deleted"
}, },
"couldNotDeleteTag": "Could not delete tag, please try again.", "couldNotDeleteTag": "No se pudo borrar la etiqueta, intente nuevamente.",
"@couldNotDeleteTag": { "@couldNotDeleteTag": {
"description": "Message shown when a tag could not be deleted" "description": "Message shown when a tag could not be deleted"
}, },
"couldNotDeleteStoragePath": "Could not delete storage path, please try again.", "couldNotDeleteStoragePath": "No se pudo remover la ruta de almacenamiento, intente nuevamente.",
"@couldNotDeleteStoragePath": { "@couldNotDeleteStoragePath": {
"description": "Message shown when a storage path could not be deleted" "description": "Message shown when a storage path could not be deleted"
}, },
"couldNotUpdateCorrespondent": "Could not update correspondent, please try again.", "couldNotUpdateCorrespondent": "No se pudo actualizar el interlocutor, intente nuevamente.",
"@couldNotUpdateCorrespondent": { "@couldNotUpdateCorrespondent": {
"description": "Message shown when a correspondent could not be updated" "description": "Message shown when a correspondent could not be updated"
}, },
"couldNotUpdateDocumentType": "Could not update document type, please try again.", "couldNotUpdateDocumentType": "No se pudo actualizar el tipo de documento, intente nuevamente.",
"@couldNotUpdateDocumentType": { "@couldNotUpdateDocumentType": {
"description": "Message shown when a document type could not be updated" "description": "Message shown when a document type could not be updated"
}, },
"couldNotUpdateTag": "Could not update tag, please try again.", "couldNotUpdateTag": "No se pudo actualizar la etiqueta, intente nuevamente.",
"@couldNotUpdateTag": { "@couldNotUpdateTag": {
"description": "Message shown when a tag could not be updated" "description": "Message shown when a tag could not be updated"
}, },
"couldNotLoadServerInformation": "Could not load server information.", "couldNotLoadServerInformation": "No se pudo cargar la información del servidor.",
"@couldNotLoadServerInformation": { "@couldNotLoadServerInformation": {
"description": "Message shown when the server information could not be loaded" "description": "Message shown when the server information could not be loaded"
}, },
"couldNotLoadStatistics": "Could not load server statistics.", "couldNotLoadStatistics": "No se pudieron cargar las estasticas del servidor.",
"@couldNotLoadStatistics": { "@couldNotLoadStatistics": {
"description": "Message shown when the server statistics could not be loaded" "description": "Message shown when the server statistics could not be loaded"
}, },
"couldNotLoadUISettings": "Could not load UI settings.", "couldNotLoadUISettings": "No se pudieron cargar los ajustes de UI.",
"@couldNotLoadUISettings": { "@couldNotLoadUISettings": {
"description": "Message shown when the UI settings could not be loaded" "description": "Message shown when the UI settings could not be loaded"
}, },
"couldNotLoadTasks": "Could not load tasks.", "couldNotLoadTasks": "No se pudieron cargar tareas.",
"@couldNotLoadTasks": { "@couldNotLoadTasks": {
"description": "Message shown when the tasks (e.g. document consumed) could not be loaded" "description": "Message shown when the tasks (e.g. document consumed) could not be loaded"
}, },
"userNotFound": "User could not be found.", "userNotFound": "Usuario no encontrado.",
"@userNotFound": { "@userNotFound": {
"description": "Message shown when the specified user (e.g. by id) could not be found" "description": "Message shown when the specified user (e.g. by id) could not be found"
}, },
"couldNotUpdateSavedView": "Could not update saved view, please try again.", "couldNotUpdateSavedView": "No se pudo actualizar la vista guardada, intente nuevamente.",
"@couldNotUpdateSavedView": { "@couldNotUpdateSavedView": {
"description": "Message shown when a saved view could not be updated" "description": "Message shown when a saved view could not be updated"
}, },
"couldNotUpdateStoragePath": "Could not update storage path, please try again.", "couldNotUpdateStoragePath": "No se pudo actualizar la ruta de almacenamiento, intente nuevamente.",
"savedViewSuccessfullyUpdated": "Saved view successfully updated.", "savedViewSuccessfullyUpdated": "La vista guardada se actualizó correctamente.",
"@savedViewSuccessfullyUpdated": { "@savedViewSuccessfullyUpdated": {
"description": "Message shown when a saved view was successfully updated." "description": "Message shown when a saved view was successfully updated."
}, },
"discardChanges": "Discard changes?", "discardChanges": "¿Descartar cambios?",
"@discardChanges": { "@discardChanges": {
"description": "Title of the alert dialog shown when a user tries to close a view with unsaved changes." "description": "Title of the alert dialog shown when a user tries to close a view with unsaved changes."
}, },
"savedViewChangedDialogContent": "The filter conditions of the active view have changed. By resetting the filter, these changes will be lost. Do you still wish to continue?", "savedViewChangedDialogContent": "Han cambiado las condiciones de filtrado de la vista activa. Al reiniciar el filtro se perderán estos cambios. ¿Desea continuar?",
"@savedViewChangedDialogContent": { "@savedViewChangedDialogContent": {
"description": "Content of the alert dialog shown when all of the following applies:\r\n* User has saved view selected\r\n* User has performed changes to the current document filter\r\n* User now tries to reset this filter without having saved the changes to the view." "description": "Content of the alert dialog shown when all of the following applies:\r\n* User has saved view selected\r\n* User has performed changes to the current document filter\r\n* User now tries to reset this filter without having saved the changes to the view."
}, },
"createFromCurrentFilter": "Create from current filter", "createFromCurrentFilter": "Crear desde el filtro actual",
"@createFromCurrentFilter": { "@createFromCurrentFilter": {
"description": "Tooltip of the \"New saved view\" button" "description": "Tooltip of the \"New saved view\" button"
}, },
"home": "Home", "home": "Inicio",
"@home": { "@home": {
"description": "Label of the \"Home\" route" "description": "Label of the \"Home\" route"
}, },
"welcomeUser": "Welcome, {name}!", "welcomeUser": "¡Bienvenido, {name}!",
"@welcomeUser": { "@welcomeUser": {
"description": "Top message shown on the home page" "description": "Top message shown on the home page"
}, },
"noSavedViewOnHomepageHint": "Configure a saved view to be displayed on your home page and it will show up here.", "noSavedViewOnHomepageHint": "Configure una vista guardada para que sea mostrada en su página de inicio y aparecerá aquí.",
"@noSavedViewOnHomepageHint": { "@noSavedViewOnHomepageHint": {
"description": "Message shown when there is no saved view to display on the home page." "description": "Message shown when there is no saved view to display on the home page."
}, },
"statistics": "Statistics", "statistics": "Estadísticas",
"documentsInInbox": "Documents in inbox", "documentsInInbox": "Documentos en el buzón",
"totalDocuments": "Total documents", "totalDocuments": "Total de documentos",
"totalCharacters": "Total characters", "totalCharacters": "Total de caracteres",
"showAll": "Show all", "showAll": "Mostrar todo",
"@showAll": { "@showAll": {
"description": "Button label shown on a saved view preview to open this view in the documents page" "description": "Button label shown on a saved view preview to open this view in the documents page"
}, },
"userAlreadyExists": "This user already exists.", "userAlreadyExists": "Este usuario ya existe.",
"@userAlreadyExists": { "@userAlreadyExists": {
"description": "Error message shown when the user tries to add an already existing account." "description": "Error message shown when the user tries to add an already existing account."
}, },
"youDidNotSaveAnyViewsYet": "You did not save any views yet, create one and it will be shown here.", "youDidNotSaveAnyViewsYet": "Aún no guardaste ninguna vista, crea una y aparecerá aquí.",
"@youDidNotSaveAnyViewsYet": { "@youDidNotSaveAnyViewsYet": {
"description": "Message shown when there are no saved views yet." "description": "Message shown when there are no saved views yet."
}, },
"tryAgain": "Try again", "tryAgain": "Intente nuevamente",
"discardFile": "Discard file?", "discardFile": "¿Descartar archivo?",
"discard": "Discard", "discard": "Descartar",
"backToLogin": "Back to login", "backToLogin": "Volver al inicio de sesión",
"skipEditingReceivedFiles": "Skip editing received files", "skipEditingReceivedFiles": "Omitir edición de archivos recibidos",
"uploadWithoutPromptingUploadForm": "Always upload without prompting the upload form when sharing files with the app.", "uploadWithoutPromptingUploadForm": "Subir automáticamente sin mostrar el formulario de subida al compartir archivos con la app.",
"authenticatingDots": "Authenticating...", "authenticatingDots": "Autenticando...",
"@authenticatingDots": { "@authenticatingDots": {
"description": "Message shown when the app is authenticating the user" "description": "Message shown when the app is authenticating the user"
}, },
"persistingUserInformation": "Persisting user information...", "persistingUserInformation": "Manteniendo información del usuario...",
"fetchingUserInformation": "Fetching user information...", "fetchingUserInformation": "Obteniendo información del usuario...",
"@fetchingUserInformation": { "@fetchingUserInformation": {
"description": "Message shown when the app loads user data from the server" "description": "Message shown when the app loads user data from the server"
}, },
"restoringSession": "Restoring session...", "restoringSession": "Restaurando sesn...",
"@restoringSession": { "@restoringSession": {
"description": "Message shown when the user opens the app and the previous user is tried to be authenticated and logged in" "description": "Message shown when the user opens the app and the previous user is tried to be authenticated and logged in"
},
"documentsAssigned": "{count, plural, zero{No documents} one{1 document} other{{count} documents}}",
"@documentsAssigned": {
"description": "Text shown with a correspondent, document type etc. to indicate the number of documents this filter will maximally yield."
},
"discardChangesWarning": "You have unsaved changes. By continuing, all changes will be lost. Do you want to discard these changes?",
"@discardChangesWarning": {
"description": "Warning message shown when the user tries to close a route without saving the changes."
} }
} }

View File

@@ -996,5 +996,13 @@
"restoringSession": "Restoring session...", "restoringSession": "Restoring session...",
"@restoringSession": { "@restoringSession": {
"description": "Message shown when the user opens the app and the previous user is tried to be authenticated and logged in" "description": "Message shown when the user opens the app and the previous user is tried to be authenticated and logged in"
},
"documentsAssigned": "{count, plural, zero{No documents} one{1 document} other{{count} documents}}",
"@documentsAssigned": {
"description": "Text shown with a correspondent, document type etc. to indicate the number of documents this filter will maximally yield."
},
"discardChangesWarning": "You have unsaved changes. By continuing, all changes will be lost. Do you want to discard these changes?",
"@discardChangesWarning": {
"description": "Warning message shown when the user tries to close a route without saving the changes."
} }
} }

View File

@@ -996,5 +996,13 @@
"restoringSession": "Restoring session...", "restoringSession": "Restoring session...",
"@restoringSession": { "@restoringSession": {
"description": "Message shown when the user opens the app and the previous user is tried to be authenticated and logged in" "description": "Message shown when the user opens the app and the previous user is tried to be authenticated and logged in"
},
"documentsAssigned": "{count, plural, zero{No documents} one{1 document} other{{count} documents}}",
"@documentsAssigned": {
"description": "Text shown with a correspondent, document type etc. to indicate the number of documents this filter will maximally yield."
},
"discardChangesWarning": "You have unsaved changes. By continuing, all changes will be lost. Do you want to discard these changes?",
"@discardChangesWarning": {
"description": "Warning message shown when the user tries to close a route without saving the changes."
} }
} }

View File

@@ -996,5 +996,13 @@
"restoringSession": "Restoring session...", "restoringSession": "Restoring session...",
"@restoringSession": { "@restoringSession": {
"description": "Message shown when the user opens the app and the previous user is tried to be authenticated and logged in" "description": "Message shown when the user opens the app and the previous user is tried to be authenticated and logged in"
},
"documentsAssigned": "{count, plural, zero{No documents} one{1 document} other{{count} documents}}",
"@documentsAssigned": {
"description": "Text shown with a correspondent, document type etc. to indicate the number of documents this filter will maximally yield."
},
"discardChangesWarning": "You have unsaved changes. By continuing, all changes will be lost. Do you want to discard these changes?",
"@discardChangesWarning": {
"description": "Warning message shown when the user tries to close a route without saving the changes."
} }
} }

View File

@@ -996,5 +996,13 @@
"restoringSession": "Restoring session...", "restoringSession": "Restoring session...",
"@restoringSession": { "@restoringSession": {
"description": "Message shown when the user opens the app and the previous user is tried to be authenticated and logged in" "description": "Message shown when the user opens the app and the previous user is tried to be authenticated and logged in"
},
"documentsAssigned": "{count, plural, zero{No documents} one{1 document} other{{count} documents}}",
"@documentsAssigned": {
"description": "Text shown with a correspondent, document type etc. to indicate the number of documents this filter will maximally yield."
},
"discardChangesWarning": "You have unsaved changes. By continuing, all changes will be lost. Do you want to discard these changes?",
"@discardChangesWarning": {
"description": "Warning message shown when the user tries to close a route without saving the changes."
} }
} }

View File

@@ -52,7 +52,9 @@ class DocumentDetailsRoute extends GoRouteData {
context.read(), context.read(),
context.read(), context.read(),
initialDocument: $extra, initialDocument: $extra,
), )
..loadFullContent()
..loadMetaData(),
lazy: false, lazy: false,
child: DocumentDetailsPage( child: DocumentDetailsPage(
isLabelClickable: isLabelClickable, isLabelClickable: isLabelClickable,

View File

@@ -1,3 +1,4 @@
import 'package:equatable/equatable.dart';
import 'package:json_annotation/json_annotation.dart'; import 'package:json_annotation/json_annotation.dart';
import 'package:paperless_api/src/converters/local_date_time_json_converter.dart'; import 'package:paperless_api/src/converters/local_date_time_json_converter.dart';
import 'package:paperless_api/src/models/document_model.dart'; import 'package:paperless_api/src/models/document_model.dart';
@@ -6,7 +7,7 @@ part 'field_suggestions.g.dart';
@LocalDateTimeJsonConverter() @LocalDateTimeJsonConverter()
@JsonSerializable(fieldRename: FieldRename.snake) @JsonSerializable(fieldRename: FieldRename.snake)
class FieldSuggestions { class FieldSuggestions with EquatableMixin {
final int? documentId; final int? documentId;
final Iterable<int> correspondents; final Iterable<int> correspondents;
final Iterable<int> tags; final Iterable<int> tags;
@@ -95,4 +96,13 @@ class FieldSuggestions {
_$FieldSuggestionsFromJson(json); _$FieldSuggestionsFromJson(json);
Map<String, dynamic> toJson() => _$FieldSuggestionsToJson(this); Map<String, dynamic> toJson() => _$FieldSuggestionsToJson(this);
@override
List<Object?> get props => [
documentId,
correspondents,
tags,
documentTypes,
dates,
];
} }

View File

@@ -203,7 +203,7 @@ class StoragePath extends Label {
const StoragePath({ const StoragePath({
super.id, super.id,
required super.name, required super.name,
required this.path, this.path = '',
super.slug, super.slug,
super.match, super.match,
super.matchingAlgorithm, super.matchingAlgorithm,

View File

@@ -1,5 +1,8 @@
#!/bin/bash #!/bin/bash
echo "Updating source language..." echo "Updating source language..."
crowdin download sources --identity=../crowdin_credentials.yml --config ../crowdin.yml --no-preserve-hierarchy crowdin download sources --identity=../crowdin_credentials.yml --config ../crowdin.yml --preserve-hierarchy
echo "Updating translations..." echo "Updating translations..."
crowdin download --identity=../crowdin_credentials.yml --config ../crowdin.yml crowdin download --identity=../crowdin_credentials.yml --config ../crowdin.yml
pushd ..
flutter gen-l10n
popd