feat: Implement new tag form field

This commit is contained in:
Anton Stubenbord
2023-04-10 01:00:34 +02:00
parent 5eb19dbe83
commit f2fa4e16de
9 changed files with 448 additions and 102 deletions

View File

@@ -15,6 +15,7 @@ import 'package:paperless_mobile/features/document_edit/cubit/document_edit_cubi
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/tag_query_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/generated/l10n/app_localizations.dart';
@@ -57,7 +58,6 @@ class _DocumentEditPageState extends State<DocumentEditPage> {
Widget build(BuildContext context) {
return BlocBuilder<DocumentEditCubit, DocumentEditState>(
builder: (context, state) {
log("Updated state. correspondents have ${state.correspondents.length} items.");
return DefaultTabController(
length: 2,
child: Scaffold(
@@ -207,57 +207,48 @@ class _DocumentEditPageState extends State<DocumentEditPage> {
],
).padded(),
// Tag form field
TagFormField(
initialValue: IdsTagsQuery.included(
state.document.tags.toList()),
notAssignedSelectable: false,
anyAssignedSelectable: false,
excludeAllowed: false,
TagQueryFormField(
options: state.tags,
name: fkTags,
selectableOptions: state.tags,
suggestions: (_filteredSuggestions?.tags.toSet() ??
{})
.difference(state.document.tags.toSet())
.isNotEmpty
? _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;
if (currentTags is IdsTagsQuery) {
_formKey
.currentState?.fields[fkTags]
?.didChange(
(IdsTagsQuery.fromIds({
...currentTags.ids,
itemData
})));
} else {
_formKey
.currentState?.fields[fkTags]
?.didChange(
(IdsTagsQuery.fromIds(
{itemData})));
}
},
);
},
)
: null,
allowOnlySelection: true,
allowCreation: true,
allowExclude: false,
initialValue: IdsTagsQuery.included(
state.document.tags,
),
).padded(),
if (_filteredSuggestions?.tags
.toSet()
.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;
if (currentTags is IdsTagsQuery) {
_formKey.currentState?.fields[fkTags]
?.didChange((IdsTagsQuery.fromIds(
{...currentTags.ids, itemData})));
} else {
_formKey.currentState?.fields[fkTags]
?.didChange((IdsTagsQuery.fromIds(
{itemData})));
}
},
);
},
),
// Prevent tags from being hidden by fab
const SizedBox(height: 64),
],

View File

@@ -194,9 +194,12 @@ class _DocumentFilterFormState extends State<DocumentFilterForm> {
Widget _buildTagsFormField() {
return TagQueryFormField(
allowExclude: false,
options: widget.tags,
name: DocumentModel.tagsKey,
initialValue: widget.initialFilter.tags,
allowOnlySelection: false,
allowCreation: false,
);
return TagFormField(
name: DocumentModel.tagsKey,

View File

@@ -1,16 +1,321 @@
import 'package:flutter/src/widgets/framework.dart';
import 'package:flutter/src/widgets/placeholder.dart';
import 'package:collection/collection.dart';
import 'package:flutter/material.dart';
import 'package:flutter/scheduler.dart';
import 'package:paperless_api/paperless_api.dart';
import 'package:paperless_mobile/features/edit_label/view/impl/add_tag_page.dart';
import 'package:paperless_mobile/generated/l10n/app_localizations.dart';
class FullscreenTagsForm extends StatefulWidget {
const FullscreenTagsForm({super.key});
final TagsQuery? initialValue;
final Map<int, Tag> options;
final void Function({TagsQuery? returnValue}) onSubmit;
final bool allowOnlySelection;
final bool allowCreation;
final bool allowExclude;
const FullscreenTagsForm({
super.key,
this.initialValue,
required this.options,
required this.onSubmit,
required this.allowOnlySelection,
required this.allowCreation,
required this.allowExclude,
});
@override
State<FullscreenTagsForm> createState() => _FullscreenTagsFormState();
}
class _FullscreenTagsFormState extends State<FullscreenTagsForm> {
late bool _showClearIcon = false;
final _textEditingController = TextEditingController();
final _focusNode = FocusNode();
late List<Tag> _options;
List<int> _include = [];
List<int> _exclude = [];
bool _anyAssigned = false;
bool _notAssigned = false;
@override
void initState() {
super.initState();
_options = widget.options.values.toList();
final value = widget.initialValue;
if (value is IdsTagsQuery) {
_include = value.includedIds.toList();
_exclude = value.excludedIds.toList();
} else if (value is AnyAssignedTagsQuery) {
_include = value.tagIds.toList();
_anyAssigned = true;
} else if (value is OnlyNotAssignedTagsQuery) {
_notAssigned = true;
}
_textEditingController.addListener(() => setState(() {
_showClearIcon = _textEditingController.text.isNotEmpty;
}));
WidgetsBinding.instance.addPostFrameCallback((timeStamp) {
//Delay keyboard popup to ensure open animation is finished before.
Future.delayed(
const Duration(milliseconds: 200),
() => _focusNode.requestFocus(),
);
});
}
@override
Widget build(BuildContext context) {
return const Placeholder();
final theme = Theme.of(context);
return Scaffold(
floatingActionButton: widget.allowCreation
? FloatingActionButton(
onPressed: _onAddTag,
child: Icon(Icons.add),
)
: null,
appBar: AppBar(
backgroundColor: theme.colorScheme.surface,
toolbarHeight: 72,
leading: BackButton(
color: theme.colorScheme.onSurface,
),
title: TextFormField(
focusNode: _focusNode,
controller: _textEditingController,
autofocus: true,
style: theme.textTheme.bodyLarge?.apply(
color: theme.colorScheme.onSurface,
),
decoration: InputDecoration(
contentPadding: EdgeInsets.zero,
hintStyle: theme.textTheme.bodyLarge?.apply(
color: theme.colorScheme.onSurfaceVariant,
),
icon: const Icon(Icons.label_outline),
hintText: S.of(context)!.startTyping,
border: InputBorder.none,
),
textInputAction: TextInputAction.done,
),
actions: [
if (_showClearIcon)
IconButton(
icon: const Icon(Icons.clear),
onPressed: () {
_textEditingController.clear();
},
),
IconButton(
tooltip: S.of(context)!.done,
icon: const Icon(Icons.done),
onPressed: () {
if (widget.allowOnlySelection) {
widget.onSubmit(returnValue: IdsTagsQuery.included(_include));
return;
}
late final TagsQuery query;
if (_notAssigned) {
query = const OnlyNotAssignedTagsQuery();
} else if (_anyAssigned) {
query = AnyAssignedTagsQuery(tagIds: _include);
} else {
query = IdsTagsQuery([
for (var id in _include) IncludeTagIdQuery(id),
for (var id in _exclude) ExcludeTagIdQuery(id),
]);
}
widget.onSubmit(returnValue: query);
},
),
],
bottom: PreferredSize(
preferredSize: !widget.allowOnlySelection
? const Size.fromHeight(32)
: const Size.fromHeight(1),
child: Column(
children: [
Divider(color: theme.colorScheme.outline),
if (!widget.allowOnlySelection)
SizedBox(
width: double.infinity,
child: SegmentedButton<bool>(
segments: [
ButtonSegment(
enabled: isSegmentedButtonEnabled,
value: false,
label: const Text("All"), //TODO: INTL
),
ButtonSegment(
enabled: isSegmentedButtonEnabled,
value: true,
label: Text(S.of(context)!.anyAssigned),
),
],
multiSelectionEnabled: false,
emptySelectionAllowed: true,
onSelectionChanged: (value) {
setState(() {
_anyAssigned = value.first;
});
},
selected: {_anyAssigned},
),
),
],
),
),
),
body: Builder(
builder: (context) {
final options = _buildOptions(_textEditingController.text);
return Column(
children: [
Expanded(
child: ListView.builder(
padding: EdgeInsets.zero,
shrinkWrap: true,
itemCount: options.length,
itemBuilder: (BuildContext context, int index) {
return options.elementAt(index);
},
),
),
],
);
},
),
);
}
void _onAddTag() async {
final createdTag = await Navigator.of(context).push<Tag?>(
MaterialPageRoute(
builder: (context) => AddTagPage(
initialValue: _textEditingController.text,
),
),
);
_textEditingController.clear();
if (createdTag != null) {
setState(() {
_options.add(createdTag);
_toggleSelection(createdTag.id!);
});
}
}
bool get isSegmentedButtonEnabled {
return _exclude.isEmpty && _include.length > 1;
}
Widget _buildNotAssignedOption() {
return ListTile(
title: Text(S.of(context)!.notAssigned),
trailing: _notAssigned ? const Icon(Icons.done) : null,
onTap: () {
setState(() {
_notAssigned = !_notAssigned;
_include = [];
_exclude = [];
});
},
);
}
///
/// Filters the options passed to this widget by the current [query] and
/// adds not-/any assigned options
///
Iterable<Widget> _buildOptions(String query) sync* {
final normalizedQuery = query.trim().toLowerCase();
if (!widget.allowOnlySelection &&
S.of(context)!.notAssigned.toLowerCase().contains(normalizedQuery)) {
yield _buildNotAssignedOption();
}
var matches = _options
.where((e) => e.name.trim().toLowerCase().contains(normalizedQuery));
if (matches.isEmpty && widget.allowCreation) {
yield Text(S.of(context)!.noItemsFound);
yield TextButton(
child: Text(S.of(context)!.addTag),
onPressed: _onAddTag,
);
}
for (final tag in matches) {
yield SelectableTagWidget(
tag: tag,
excluded: _exclude.contains(tag.id),
selected: _include.contains(tag.id),
onTap: () => _toggleSelection(tag.id!),
);
}
}
void _toggleSelection(int id) {
if (widget.allowOnlySelection || widget.allowExclude) {
if (_include.contains(id)) {
setState(() => _include.remove(id));
} else {
setState(() => _include.add(id));
}
} else {
if (_include.contains(id)) {
setState(() {
_notAssigned = false;
_anyAssigned = false;
_include.remove(id);
_exclude.add(id);
});
} else if (_exclude.contains(id)) {
setState(() {
_notAssigned = false;
_exclude.remove(id);
});
} else {
setState(() {
_notAssigned = false;
_include.add(id);
});
}
}
}
}
class SelectableTagWidget extends StatelessWidget {
final Tag tag;
final bool selected;
final bool excluded;
final VoidCallback onTap;
const SelectableTagWidget({
super.key,
required this.tag,
required this.excluded,
required this.selected,
required this.onTap,
});
@override
Widget build(BuildContext context) {
return ListTile(
title: Text(tag.name),
trailing: excluded
? const Icon(Icons.close)
: (selected ? const Icon(Icons.done) : null),
leading: CircleAvatar(
backgroundColor: tag.color,
child: (tag.isInboxTag ?? false)
? Icon(
Icons.inbox,
color: tag.textColor,
)
: null,
),
onTap: onTap,
);
}
}

View File

@@ -1,65 +1,84 @@
import 'dart:developer';
import 'package:animations/animations.dart';
import 'package:flutter/material.dart';
import 'package:flutter/rendering.dart';
import 'package:flutter_form_builder/flutter_form_builder.dart';
import 'package:paperless_api/paperless_api.dart';
import 'package:paperless_mobile/core/workarounds/colored_chip.dart';
import 'package:paperless_mobile/features/labels/tags/view/widgets/fullscreen_tags_form.dart';
import 'package:paperless_mobile/generated/l10n/app_localizations.dart';
class TagQueryFormField extends StatelessWidget {
final String name;
final Map<int, Tag> options;
final TagsQuery? initialValue;
final bool allowOnlySelection;
final bool allowCreation;
final bool allowExclude;
const TagQueryFormField({
super.key,
required this.options,
this.initialValue,
required this.name,
required this.allowOnlySelection,
required this.allowCreation,
required this.allowExclude,
});
@override
Widget build(BuildContext context) {
log(initialValue.toString());
return FormBuilderField<TagsQuery?>(
initialValue: initialValue,
builder: (field) {
final values = _generateOptions(context, field.value, field).toList();
final isEmpty = (field.value is IdsTagsQuery &&
(field.value as IdsTagsQuery).ids.isEmpty) ||
field.value == null;
final values = _generateOptions(context, field.value, field).toList();
return GestureDetector(
onTap: () {
showDialog(
context: context,
builder: (context) => Dialog.fullscreen(
child: Scaffold(
appBar: AppBar(
title: Text("Test"),
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) => SizedBox(width: 4),
itemBuilder: (context, index) => values[index],
itemCount: values.length,
),
),
),
),
);
},
child: InputDecorator(
isEmpty: isEmpty,
decoration: InputDecoration(
contentPadding: const EdgeInsets.all(12),
labelText: S.of(context)!.tags,
prefixIcon: const Icon(Icons.label_outline),
),
child: SizedBox(
height: 32,
child: ListView.separated(
scrollDirection: Axis.horizontal,
separatorBuilder: (context, index) => 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,
allowExclude: allowExclude,
),
onClosed: (data) {
if (data != null) {
field.didChange(data);
}
},
);
},
name: name,
@@ -80,7 +99,9 @@ class TagQueryFormField extends StatelessWidget {
} else if (query is OnlyNotAssignedTagsQuery) {
yield _buildNotAssignedTagWidget(context, field);
} else if (query is AnyAssignedTagsQuery) {
yield _buildAnyAssignedTagWidget(context, field);
for (final e in query.tagIds) {
yield _buildAnyAssignedTagWidget(context, e, field, query);
}
}
}
@@ -94,7 +115,9 @@ class TagQueryFormField extends StatelessWidget {
final tag = options[e.id]!;
return QueryTagChip(
onDeleted: () => field.didChange(formValue.withIdsRemoved([e.id])),
onSelected: () => field.didChange(formValue.withIdQueryToggled(e.id)),
onSelected: allowExclude
? () => field.didChange(formValue.withIdQueryToggled(e.id))
: null,
exclude: e is ExcludeTagIdQuery,
backgroundColor: tag.color,
foregroundColor: tag.textColor,
@@ -116,13 +139,24 @@ class TagQueryFormField extends StatelessWidget {
}
Widget _buildAnyAssignedTagWidget(
BuildContext context, FormFieldState<TagsQuery?> field) {
BuildContext context,
int e,
FormFieldState<TagsQuery?> field,
AnyAssignedTagsQuery query,
) {
return QueryTagChip(
onDeleted: () => field.didChange(const IdsTagsQuery()),
onDeleted: () {
final updatedQuery = query.withRemoved([e]);
if (updatedQuery.tagIds.isEmpty) {
field.didChange(const IdsTagsQuery());
} else {
field.didChange(updatedQuery);
}
},
exclude: false,
backgroundColor: Colors.grey,
foregroundColor: Colors.black,
labelText: S.of(context)!.anyAssigned,
backgroundColor: options[e]!.color,
foregroundColor: options[e]!.textColor,
labelText: options[e]!.name,
);
}
}

View File

@@ -1,7 +1,5 @@
import 'dart:developer';
import 'package:animations/animations.dart';
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';
@@ -11,8 +9,6 @@ import 'package:paperless_mobile/core/repository/label_repository.dart';
import 'package:paperless_mobile/core/workarounds/colored_chip.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/labels/tags/view/widgets/fullscreen_tags_form.dart';
import 'package:paperless_mobile/features/labels/tags/view/widgets/tag_widget.dart';
import 'package:paperless_mobile/generated/l10n/app_localizations.dart';
class TagFormField extends StatefulWidget {

View File

@@ -81,7 +81,7 @@ class LabelFormField<T extends Label> extends StatelessWidget {
openElevation: 0,
closedElevation: 0,
closedBuilder: (context, openForm) => Container(
margin: const EdgeInsets.only(top: 4),
margin: const EdgeInsets.only(top: 6),
child: TextField(
controller: controller,
onTap: openForm,