feat: Make label fields less restrictive, improve change detection in document edit page

This commit is contained in:
Anton Stubenbord
2023-10-10 15:27:58 +02:00
parent 0b4b7f6871
commit 379b71008a
25 changed files with 597 additions and 391 deletions

View File

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

View File

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

View File

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

View File

@@ -2,44 +2,50 @@ import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:paperless_api/paperless_api.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/generated/l10n/app_localizations.dart';
class DocumentContentWidget extends StatelessWidget {
final bool isFullContentLoaded;
final String? fullContent;
final String? queryString;
final DocumentModel document;
const DocumentContentWidget({
super.key,
required this.isFullContentLoaded,
this.fullContent,
required this.document,
this.queryString,
});
@override
Widget build(BuildContext context) {
final screenWidth = MediaQuery.sizeOf(context).width;
return SliverToBoxAdapter(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
HighlightedText(
text: (isFullContentLoaded ? fullContent : document.content) ?? "",
text: document.content ?? '',
highlights: queryString != null ? queryString!.split(" ") : [],
style: Theme.of(context).textTheme.bodyMedium,
caseSensitive: false,
),
if (!isFullContentLoaded && (document.content ?? '').isNotEmpty)
Align(
alignment: Alignment.bottomCenter,
child: TextButton(
child: Text(S.of(context)!.loadFullContent),
onPressed: () {
context.read<DocumentDetailsCubit>().loadFullContent();
},
if (!isFullContentLoaded)
ShimmerPlaceholder(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
for (var scale in [0.5, 0.9, 0.5, 0.8, 0.9, 0.9])
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);
_labelRepository.addListener(
this,
onChanged: (labels) => emit(state.copyWith(
correspondents: labels.correspondents,
documentTypes: labels.documentTypes,
storagePaths: labels.storagePaths,
tags: labels.tags,
)),
onChanged: (labels) {
if (isClosed) {
return;
}
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 'package:collection/collection.dart';
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.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:paperless_api/paperless_api.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/workarounds/colored_chip.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/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/view/widgets/label_form_field.dart';
import 'package:paperless_mobile/generated/l10n/app_localizations.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 {
const DocumentEditPage({
@@ -39,18 +40,79 @@ class _DocumentEditPageState extends State<DocumentEditPage> {
static const fkStoragePath = 'storagePath';
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
Widget build(BuildContext context) {
final currentUser = context.watch<LocalUserAccount>().paperlessUser;
return PopWithUnsavedChanges(
hasChangesPredicate: () => _formKey.currentState?.isDirty ?? false,
child: BlocBuilder<DocumentEditCubit, DocumentEditState>(
builder: (context, state) {
final filteredSuggestions = state.suggestions?.documentDifference(
context.read<DocumentEditCubit>().state.document);
return DefaultTabController(
return BlocConsumer<DocumentEditCubit, DocumentEditState>(
listenWhen: (previous, current) =>
previous.document.content != current.document.content,
listener: (context, state) {
final contentField = _formKey.currentState?.fields[fkContent];
if (contentField == null) {
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,
child: Scaffold(
resizeToAvoidBottomInset: false,
@@ -95,13 +157,11 @@ class _DocumentEditPageState extends State<DocumentEditPage> {
LabelFormField<Correspondent>(
showAnyAssignedOption: false,
showNotAssignedOption: false,
addLabelPageBuilder: (initialValue) =>
RepositoryProvider.value(
value: context.read<LabelRepository>(),
child: AddCorrespondentPage(
initialName: initialValue,
),
),
onAddLabel: (currentInput) =>
CreateLabelRoute(
LabelType.correspondent,
name: currentInput,
).push<Correspondent>(context),
addLabelText:
S.of(context)!.addCorrespondent,
labelText: S.of(context)!.correspondent,
@@ -121,26 +181,10 @@ class _DocumentEditPageState extends State<DocumentEditPage> {
allowSelectUnassigned: true,
canCreateNewLabel:
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(),
// DocumentType form field
@@ -150,13 +194,11 @@ class _DocumentEditPageState extends State<DocumentEditPage> {
LabelFormField<DocumentType>(
showAnyAssignedOption: false,
showNotAssignedOption: false,
addLabelPageBuilder: (currentInput) =>
RepositoryProvider.value(
value: context.read<LabelRepository>(),
child: AddDocumentTypePage(
initialName: currentInput,
),
),
onAddLabel: (currentInput) =>
CreateLabelRoute(
LabelType.documentType,
name: currentInput,
).push<DocumentType>(context),
canCreateNewLabel:
currentUser.canCreateDocumentTypes,
addLabelText:
@@ -172,24 +214,10 @@ class _DocumentEditPageState extends State<DocumentEditPage> {
prefixIcon:
const Icon(Icons.description_outlined),
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(),
// StoragePath form field
@@ -199,12 +227,11 @@ class _DocumentEditPageState extends State<DocumentEditPage> {
LabelFormField<StoragePath>(
showAnyAssignedOption: false,
showNotAssignedOption: false,
addLabelPageBuilder: (initialValue) =>
RepositoryProvider.value(
value: context.read<LabelRepository>(),
child: AddStoragePathPage(
initialName: initialValue),
),
onAddLabel: (currentInput) =>
CreateLabelRoute(
LabelType.storagePath,
name: currentInput,
).push<StoragePath>(context),
canCreateNewLabel:
currentUser.canCreateStoragePaths,
addLabelText: S.of(context)!.addStoragePath,
@@ -230,6 +257,7 @@ class _DocumentEditPageState extends State<DocumentEditPage> {
allowOnlySelection: true,
allowCreation: true,
allowExclude: false,
suggestions: filteredSuggestions?.tags ?? [],
initialValue: IdsTagsQuery(
include: state.document.tags.toList(),
),
@@ -239,40 +267,7 @@ class _DocumentEditPageState extends State<DocumentEditPage> {
.difference(state.document.tags.toSet())
.isNotEmpty ??
false)
_buildSuggestionsSkeleton<int>(
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),
const SizedBox(height: 64),
],
),
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 {
if (_formKey.currentState?.saveAndValidate() ?? false) {
final values = _formKey.currentState!.value;
final correspondentParam = values[fkCorrespondent] as IdQueryParameter?;
final documentTypeParam = values[fkDocumentType] as IdQueryParameter?;
final storagePathParam = values[fkStoragePath] as IdQueryParameter?;
final tagsParam = values[fkTags] as TagsQuery?;
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 (
title,
correspondent,
documentType,
storagePath,
tags,
createdAt,
content
) = _currentValues;
var mergedDocument = document.copyWith(
title: values[fkTitle],
created: values[fkCreatedDate],
title: title,
created: createdAt,
correspondent: () => correspondent,
documentType: () => documentType,
storagePath: () => storagePath,
tags: tags,
content: values[fkContent],
content: content,
);
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/generated/l10n/app_localizations.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';
class DocumentUploadResult {
@@ -251,19 +253,10 @@ class _DocumentUploadPreparationPageState
LabelFormField<Correspondent>(
showAnyAssignedOption: false,
showNotAssignedOption: false,
addLabelPageBuilder: (initialName) =>
MultiProvider(
providers: [
Provider.value(
value: context.read<LabelRepository>(),
),
Provider.value(
value: context.read<ApiVersion>(),
)
],
child: AddCorrespondentPage(
initialName: initialName),
),
onAddLabel: (initialName) => CreateLabelRoute(
LabelType.correspondent,
name: initialName,
).push<Correspondent>(context),
addLabelText: S.of(context)!.addCorrespondent,
labelText: S.of(context)!.correspondent + " *",
name: DocumentModel.correspondentKey,
@@ -283,19 +276,10 @@ class _DocumentUploadPreparationPageState
LabelFormField<DocumentType>(
showAnyAssignedOption: false,
showNotAssignedOption: false,
addLabelPageBuilder: (initialName) =>
MultiProvider(
providers: [
Provider.value(
value: context.read<LabelRepository>(),
),
Provider.value(
value: context.read<ApiVersion>(),
)
],
child: AddDocumentTypePage(
initialName: initialName),
),
onAddLabel: (initialName) => CreateLabelRoute(
LabelType.documentType,
name: initialName,
).push<DocumentType>(context),
addLabelText: S.of(context)!.addDocumentType,
labelText: S.of(context)!.documentType + " *",
name: DocumentModel.documentTypeKey,

View File

@@ -1,8 +1,11 @@
import 'package:collection/collection.dart';
import 'package:flutter/material.dart';
import 'package:paperless_api/paperless_api.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/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 {
final TagsQuery? initialValue;
@@ -46,7 +49,7 @@ class _FullscreenTagsFormState extends State<FullscreenTagsForm> {
final value = widget.initialValue;
if (value is IdsTagsQuery) {
_include = value.include.toList();
_exclude = value.include.toList();
_exclude = value.exclude.toList();
} else if (value is AnyAssignedTagsQuery) {
_include = value.tagIds.toList();
_anyAssigned = true;
@@ -116,16 +119,26 @@ class _FullscreenTagsFormState extends State<FullscreenTagsForm> {
icon: const Icon(Icons.done),
onPressed: () {
if (widget.allowOnlySelection) {
widget.onSubmit(returnValue: IdsTagsQuery(include: _include));
widget.onSubmit(
returnValue: IdsTagsQuery(
include:
_include.sortedBy((id) => widget.options[id]!.name),
),
);
return;
}
late final TagsQuery query;
if (_notAssigned) {
query = const NotAssignedTagsQuery();
} else if (_anyAssigned) {
query = AnyAssignedTagsQuery(tagIds: _include);
query = AnyAssignedTagsQuery(
tagIds: _include.sortedBy((id) => widget.options[id]!.name),
);
} 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);
},
@@ -191,13 +204,9 @@ class _FullscreenTagsFormState extends State<FullscreenTagsForm> {
}
void _onAddTag() async {
final createdTag = await Navigator.of(context).push<Tag?>(
MaterialPageRoute(
builder: (context) => AddTagPage(
initialName: _textEditingController.text,
),
),
);
final createdTag =
await CreateLabelRoute(LabelType.tag, name: _textEditingController.text)
.push<Tag>(context);
_textEditingController.clear();
if (createdTag != null) {
setState(() {
@@ -308,21 +317,21 @@ class SelectableTagWidget extends StatelessWidget {
@override
Widget build(BuildContext context) {
final includeColor = Colors.green.withOpacity(0.3);
final excludeColor = Colors.red.withOpacity(0.3);
return ListTile(
title: Text(tag.name),
trailing: excluded
? const Icon(Icons.close)
: (selected ? const Icon(Icons.done) : null),
trailing: Text(S.of(context)!.documentsAssigned(tag.documentCount ?? 0)),
leading: CircleAvatar(
backgroundColor: tag.color,
child: (tag.isInboxTag)
? Icon(
Icons.inbox,
color: tag.textColor,
)
: null,
child: tag.isInboxTag ? Icon(Icons.inbox, color: tag.textColor) : null,
),
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_mobile/core/database/tables/local_user_account.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/generated/l10n/app_localizations.dart';
@@ -16,6 +17,7 @@ class TagsFormField extends StatelessWidget {
final bool allowOnlySelection;
final bool allowCreation;
final bool allowExclude;
final Iterable<int> suggestions;
const TagsFormField({
super.key,
@@ -25,63 +27,120 @@ class TagsFormField extends StatelessWidget {
required this.allowOnlySelection,
required this.allowCreation,
required this.allowExclude,
this.suggestions = const [],
});
@override
Widget build(BuildContext context) {
final enabled = options.values.isNotEmpty || allowCreation;
return FormBuilderField<TagsQuery?>(
initialValue: initialValue,
enabled: enabled,
builder: (field) {
final values = _generateOptions(context, field.value, field).toList();
final isEmpty = (field.value is IdsTagsQuery &&
(field.value as IdsTagsQuery).include.isEmpty) ||
field.value == null;
bool anyAssigned = field.value is AnyAssignedTagsQuery;
return OpenContainer<TagsQuery>(
middleColor: Theme.of(context).colorScheme.background,
closedColor: Theme.of(context).colorScheme.background,
openColor: Theme.of(context).colorScheme.background,
closedShape: InputBorder.none,
openElevation: 0,
closedElevation: 0,
closedBuilder: (context, openForm) => Container(
margin: const EdgeInsets.only(top: 6),
child: GestureDetector(
onTap: openForm,
child: InputDecorator(
isEmpty: isEmpty,
decoration: InputDecoration(
contentPadding: const EdgeInsets.all(12),
labelText:
'${S.of(context)!.tags}${anyAssigned ? ' (${S.of(context)!.anyAssigned})' : ''}',
prefixIcon: const Icon(Icons.label_outline),
),
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,
final displayedSuggestions = switch (field.value) {
IdsTagsQuery(include: var include) =>
suggestions.toSet().difference(include.toSet()).toList(),
_ => <int>[],
};
return Column(
children: [
OpenContainer<TagsQuery>(
middleColor: Theme.of(context).colorScheme.background,
closedColor: Theme.of(context).colorScheme.background,
openColor: Theme.of(context).colorScheme.background,
closedShape: InputBorder.none,
openElevation: 0,
closedElevation: 0,
tappable: enabled,
closedBuilder: (context, openForm) => Container(
margin: const EdgeInsets.only(top: 6),
child: GestureDetector(
onTap: openForm,
child: InputDecorator(
isEmpty: isEmpty,
decoration: InputDecoration(
contentPadding: const EdgeInsets.all(12),
labelText:
'${S.of(context)!.tags}${anyAssigned ? ' (${S.of(context)!.anyAssigned})' : ''}',
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(
options: options,
onSubmit: closeForm,
initialValue: field.value,
allowOnlySelection: allowOnlySelection,
allowCreation: allowCreation &&
context.watch<LocalUserAccount>().paperlessUser.canCreateTags,
allowExclude: allowExclude,
),
onClosed: (data) {
if (data != null) {
field.didChange(data);
}
},
),
openBuilder: (context, closeForm) => FullscreenTagsForm(
options: options,
onSubmit: closeForm,
initialValue: field.value,
allowOnlySelection: allowOnlySelection,
allowCreation: allowCreation &&
context
.watch<LocalUserAccount>()
.paperlessUser
.canCreateTags,
allowExclude: allowExclude,
),
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,

View File

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

View File

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