mirror of
https://github.com/Xevion/paperless-mobile.git
synced 2025-12-10 10:08:02 -06:00
feat: Implement new tag form field
This commit is contained in:
@@ -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,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user