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

@@ -54,6 +54,8 @@ PODS:
- FMDB (2.7.5):
- FMDB/standard (= 2.7.5)
- FMDB/standard (2.7.5)
- in_app_review (0.2.0):
- Flutter
- integration_test (0.0.1):
- Flutter
- local_auth_ios (0.0.1):
@@ -99,16 +101,17 @@ DEPENDENCIES:
- flutter_local_notifications (from `.symlinks/plugins/flutter_local_notifications/ios`)
- flutter_native_splash (from `.symlinks/plugins/flutter_native_splash/ios`)
- fluttertoast (from `.symlinks/plugins/fluttertoast/ios`)
- in_app_review (from `.symlinks/plugins/in_app_review/ios`)
- integration_test (from `.symlinks/plugins/integration_test/ios`)
- local_auth_ios (from `.symlinks/plugins/local_auth_ios/ios`)
- open_filex (from `.symlinks/plugins/open_filex/ios`)
- package_info_plus (from `.symlinks/plugins/package_info_plus/ios`)
- path_provider_foundation (from `.symlinks/plugins/path_provider_foundation/darwin`)
- path_provider_foundation (from `.symlinks/plugins/path_provider_foundation/ios`)
- pdfx (from `.symlinks/plugins/pdfx/ios`)
- permission_handler_apple (from `.symlinks/plugins/permission_handler_apple/ios`)
- receive_sharing_intent (from `.symlinks/plugins/receive_sharing_intent/ios`)
- share_plus (from `.symlinks/plugins/share_plus/ios`)
- shared_preferences_foundation (from `.symlinks/plugins/shared_preferences_foundation/darwin`)
- shared_preferences_foundation (from `.symlinks/plugins/shared_preferences_foundation/ios`)
- sqflite (from `.symlinks/plugins/sqflite/ios`)
- url_launcher_ios (from `.symlinks/plugins/url_launcher_ios/ios`)
@@ -142,6 +145,8 @@ EXTERNAL SOURCES:
:path: ".symlinks/plugins/flutter_native_splash/ios"
fluttertoast:
:path: ".symlinks/plugins/fluttertoast/ios"
in_app_review:
:path: ".symlinks/plugins/in_app_review/ios"
integration_test:
:path: ".symlinks/plugins/integration_test/ios"
local_auth_ios:
@@ -151,7 +156,7 @@ EXTERNAL SOURCES:
package_info_plus:
:path: ".symlinks/plugins/package_info_plus/ios"
path_provider_foundation:
:path: ".symlinks/plugins/path_provider_foundation/darwin"
:path: ".symlinks/plugins/path_provider_foundation/ios"
pdfx:
:path: ".symlinks/plugins/pdfx/ios"
permission_handler_apple:
@@ -161,7 +166,7 @@ EXTERNAL SOURCES:
share_plus:
:path: ".symlinks/plugins/share_plus/ios"
shared_preferences_foundation:
:path: ".symlinks/plugins/shared_preferences_foundation/darwin"
:path: ".symlinks/plugins/shared_preferences_foundation/ios"
sqflite:
:path: ".symlinks/plugins/sqflite/ios"
url_launcher_ios:
@@ -180,6 +185,7 @@ SPEC CHECKSUMS:
flutter_native_splash: 52501b97d1c0a5f898d687f1646226c1f93c56ef
fluttertoast: eb263d302cc92e04176c053d2385237e9f43fad0
FMDB: 2ce00b547f966261cd18927a3ddb07cb6f3db82a
in_app_review: 318597b3a06c22bb46dc454d56828c85f444f99d
integration_test: a1e7d09bd98eca2fc37aefd79d4f41ad37bdbbe5
local_auth_ios: 0d333dde7780f669e66f19d2ff6005f3ea84008d
open_filex: 6e26e659846ec990262224a12ef1c528bb4edbe4
@@ -195,7 +201,7 @@ SPEC CHECKSUMS:
sqflite: 6d358c025f5b867b29ed92fc697fd34924e11904
SwiftyGif: 6c3eafd0ce693cad58bb63d2b2fb9bacb8552780
Toast: 91b396c56ee72a5790816f40d3a94dd357abc196
url_launcher_ios: ae1517e5e344f5544fb090b079e11f399dfbe4d2
url_launcher_ios: fb12c43172927bb5cf75aeebd073f883801f1993
WeScan: fed582f6c38014d529afb5aa9ffd1bad38fc72b7
PODFILE CHECKSUM: 7daa35cc908d9fba025075df27cb57a1ba1ebf13

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() ??
{})
allowOnlySelection: true,
allowCreation: true,
allowExclude: false,
initialValue: IdsTagsQuery.included(
state.document.tags,
),
).padded(),
if (_filteredSuggestions?.tags
.toSet()
.difference(state.document.tags.toSet())
.isNotEmpty
? _buildSuggestionsSkeleton<int>(
.isNotEmpty ??
false)
_buildSuggestionsSkeleton<int>(
suggestions:
(_filteredSuggestions?.tags.toSet() ??
{}),
(_filteredSuggestions?.tags.toSet() ?? {}),
itemBuilder: (context, itemData) {
final tag = state.tags[itemData]!;
return ActionChip(
label: Text(
tag.name,
style:
TextStyle(color: tag.textColor),
style: TextStyle(color: tag.textColor),
),
backgroundColor: tag.color,
onPressed: () {
final currentTags = _formKey
.currentState
?.fields[fkTags]
?.value as TagsQuery;
final currentTags = _formKey.currentState
?.fields[fkTags]?.value as TagsQuery;
if (currentTags is IdsTagsQuery) {
_formKey
.currentState?.fields[fkTags]
?.didChange(
(IdsTagsQuery.fromIds({
...currentTags.ids,
itemData
})));
_formKey.currentState?.fields[fkTags]
?.didChange((IdsTagsQuery.fromIds(
{...currentTags.ids, itemData})));
} else {
_formKey
.currentState?.fields[fkTags]
?.didChange(
(IdsTagsQuery.fromIds(
_formKey.currentState?.fields[fkTags]
?.didChange((IdsTagsQuery.fromIds(
{itemData})));
}
},
);
},
)
: null,
).padded(),
),
// 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,53 +1,58 @@
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,
labelText:
'${S.of(context)!.tags}${anyAssigned ? ' (${S.of(context)!.anyAssigned})' : ''}',
prefixIcon: const Icon(Icons.label_outline),
),
child: SizedBox(
@@ -60,6 +65,20 @@ class TagQueryFormField extends StatelessWidget {
),
),
),
)),
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,

View File

@@ -1,3 +1,4 @@
import 'package:collection/collection.dart';
import 'package:json_annotation/json_annotation.dart';
import 'tags_query.dart';
@@ -22,6 +23,16 @@ class AnyAssignedTagsQuery extends TagsQuery {
@override
List<Object?> get props => [tagIds];
AnyAssignedTagsQuery withRemoved(Iterable<int> ids) {
return AnyAssignedTagsQuery(
tagIds: tagIds.toSet().difference(ids.toSet()).toList(),
);
}
AnyAssignedTagsQuery withAdded(Iterable<int> ids) {
return AnyAssignedTagsQuery(tagIds: [...tagIds, ...ids]);
}
@override
Map<String, dynamic> toJson() => _$AnyAssignedTagsQueryToJson(this);

0
scripts/install_dependencies.sh Normal file → Executable file
View File