feat: Finalize bulk edits and reworked form fields

This commit is contained in:
Anton Stubenbord
2023-04-13 22:43:41 +02:00
parent 83d8abeae2
commit d621a3bbe7
41 changed files with 936 additions and 995 deletions

View File

@@ -65,7 +65,7 @@ class DocumentBulkActionCubit extends Cubit<DocumentBulkActionState> {
final deletedDocuments = state.selection
.where((element) => deletedDocumentIds.contains(element.id));
for (final doc in deletedDocuments) {
_notifier.notifyUpdated(doc);
_notifier.notifyDeleted(doc);
}
}
@@ -128,7 +128,7 @@ class DocumentBulkActionCubit extends Cubit<DocumentBulkActionState> {
final updatedDocuments = state.selection
.where((element) => modifiedDocumentIds.contains(element.id))
.map((doc) => doc.copyWith(tags: [
...doc.tags.toSet().difference(addTagIds.toSet()),
...doc.tags.toSet().difference(removeTagIds.toSet()),
...addTagIds
]));
for (final doc in updatedDocuments) {

View File

@@ -1,23 +0,0 @@
import 'package:flutter/material.dart';
import 'package:paperless_api/paperless_api.dart';
class BulkEditPage<T extends Label> extends StatefulWidget {
final bool enableMultipleChoice;
final Map<int, T> availableOptions;
const BulkEditPage({
super.key,
required this.enableMultipleChoice,
required this.availableOptions,
});
@override
State<BulkEditPage> createState() => _BulkEditPageState();
}
class _BulkEditPageState extends State<BulkEditPage> {
@override
Widget build(BuildContext context) {
return Container();
}
}

View File

@@ -1,244 +0,0 @@
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';
import 'package:flutter_typeahead/flutter_typeahead.dart';
import 'package:paperless_api/paperless_api.dart';
import 'package:paperless_mobile/core/widgets/dialog_utils/dialog_cancel_button.dart';
import 'package:paperless_mobile/extensions/flutter_extensions.dart';
import 'package:paperless_mobile/features/document_bulk_action/cubit/document_bulk_action_cubit.dart';
import 'package:paperless_mobile/generated/l10n/app_localizations.dart';
class BulkEditTagsBottomSheet extends StatefulWidget {
const BulkEditTagsBottomSheet({super.key});
@override
State<BulkEditTagsBottomSheet> createState() =>
_BulkEditTagsBottomSheetState();
}
class _BulkEditTagsBottomSheetState extends State<BulkEditTagsBottomSheet> {
final _formKey = GlobalKey<FormBuilderState>();
final _textEditingController = TextEditingController();
late Set<int> _sharedTags;
late Set<int> _nonSharedTags;
final Set<int> _sharedTagsToRemove = {};
final Set<int> _nonSharedTagsToRemove = {};
final Set<int> _tagsToAdd = {};
@override
void initState() {
super.initState();
final state = context.read<DocumentBulkActionCubit>().state;
_sharedTags = state.selection
.map((doc) => doc.tags)
.reduce((previousValue, element) =>
previousValue.toSet().intersection(element.toSet()))
.toSet();
print(_sharedTags.map((e) => e).join(", "));
_nonSharedTags = state.selection
.map((doc) => doc.tags)
.flattened
.toSet()
.difference(_sharedTags)
.toSet();
print(_nonSharedTags.map((e) => e).join(", "));
}
@override
Widget build(BuildContext context) {
return BlocBuilder<DocumentBulkActionCubit, DocumentBulkActionState>(
builder: (context, state) {
return Padding(
padding:
EdgeInsets.only(bottom: MediaQuery.of(context).viewInsets.bottom),
child: BlocBuilder<DocumentBulkActionCubit, DocumentBulkActionState>(
builder: (context, state) {
print(state);
return Padding(
padding: const EdgeInsets.all(16.0),
child: SingleChildScrollView(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
Text(
"Bulk modify tags",
style: Theme.of(context).textTheme.titleLarge,
).paddedOnly(bottom: 24),
TypeAheadFormField<Tag>(
textFieldConfiguration: TextFieldConfiguration(
controller: _textEditingController,
decoration: const InputDecoration(
labelText: "Tags",
hintText: "Start typing to add tags...",
),
),
onSuggestionSelected: (suggestion) {
setState(() {
_tagsToAdd.add(suggestion.id!);
});
_textEditingController.clear();
},
itemBuilder: (context, option) {
return ListTile(
leading: SizedBox(
width: 32,
height: 32,
child: DecoratedBox(
decoration: BoxDecoration(
shape: BoxShape.circle,
color: option.color!,
),
),
),
title: Text(option.name),
);
},
suggestionsCallback: (pattern) {
final searchString = pattern.toLowerCase();
return state.tags.entries
.where(
(tag) => tag.value.name
.toLowerCase()
.contains(searchString),
)
.map((e) => e.key)
.toSet()
.difference(_sharedTags)
.difference(_nonSharedTags)
.map((e) => state.tags[e]!);
},
),
Text("Shared tags"),
Wrap(
children: _sharedTags
.map(
(tag) => RemovableTagWidget(
tag: state.tags[tag]!,
onDeleted: (tag) {
setState(() {
_sharedTagsToRemove.add(tag);
_sharedTags.remove(tag);
});
},
),
)
.toList(),
),
const SizedBox(height: 8),
Text("Non-shared tags"),
Wrap(
children: _nonSharedTags
.map(
(tag) => RemovableTagWidget(
tag: state.tags[tag]!,
onDeleted: (tag) {
setState(() {
_nonSharedTagsToRemove.add(tag);
_nonSharedTags.remove(tag);
});
},
),
)
.toList(),
),
Text("Remove"),
Wrap(
children: _sharedTagsToRemove.map((tag) {
return RemovableTagWidget(
tag: state.tags[tag]!,
onDeleted: (tag) {
setState(() {
_sharedTagsToRemove.remove(tag);
_sharedTags.add(tag);
});
},
);
}).toList() +
_nonSharedTagsToRemove.map((tag) {
return RemovableTagWidget(
tag: state.tags[tag]!,
onDeleted: (tag) {
setState(() {
_nonSharedTagsToRemove.remove(tag);
_nonSharedTags.add(tag);
});
},
);
}).toList(),
),
const SizedBox(height: 8),
Text("Add"),
Wrap(
children: _tagsToAdd
.map(
(tag) => RemovableTagWidget(
tag: state.tags[tag]!,
onDeleted: (tag) {
setState(() {
_tagsToAdd.remove(tag);
});
}),
)
.toList(),
),
Row(
mainAxisAlignment: MainAxisAlignment.end,
children: [
const DialogCancelButton(),
const SizedBox(width: 16),
FilledButton(
onPressed: () {
if (_formKey.currentState?.saveAndValidate() ??
false) {
final value = _formKey.currentState
?.getRawValue('labelFormField')
as IdsTagsQuery;
context
.read<DocumentBulkActionCubit>()
.bulkModifyTags(
addTagIds: value.includedIds,
);
}
},
child: Text(S.of(context)!.apply),
),
],
).padded(8),
],
),
),
);
},
),
);
},
);
}
}
class RemovableTagWidget extends StatelessWidget {
final Tag tag;
final void Function(int tagId) onDeleted;
const RemovableTagWidget(
{super.key, required this.tag, required this.onDeleted});
@override
Widget build(BuildContext context) {
return Chip(
label: Text(
tag.name,
style: TextStyle(
color: tag.textColor,
),
),
onDeleted: () => onDeleted(tag.id!),
deleteIcon: Icon(Icons.clear),
backgroundColor: tag.color,
deleteIconColor: tag.textColor,
padding: EdgeInsets.zero,
side: BorderSide.none,
);
}
}

View File

@@ -0,0 +1,38 @@
import 'package:flutter/material.dart';
import 'package:paperless_mobile/core/widgets/dialog_utils/dialog_cancel_button.dart';
import 'package:paperless_mobile/core/widgets/dialog_utils/dialog_confirm_button.dart';
import 'package:paperless_mobile/generated/l10n/app_localizations.dart';
class ConfirmBulkModifyLabelDialog extends StatelessWidget {
final int selectionCount;
final String content;
const ConfirmBulkModifyLabelDialog({
super.key,
required this.selectionCount,
required this.content,
});
@override
Widget build(BuildContext context) {
return AlertDialog(
title: Text(S.of(context)!.confirmAction),
content: RichText(
text: TextSpan(
text: content,
children: [
const TextSpan(text: "\n\n"),
TextSpan(
text: S.of(context)!.areYouSureYouWantToContinue,
),
],
),
),
actions: const [
DialogCancelButton(),
DialogConfirmButton(
style: DialogConfirmButtonStyle.danger,
),
],
);
}
}

View File

@@ -0,0 +1,62 @@
import 'package:flutter/material.dart';
import 'package:flutter/src/widgets/framework.dart';
import 'package:flutter/src/widgets/placeholder.dart';
import 'package:paperless_mobile/core/widgets/dialog_utils/dialog_cancel_button.dart';
import 'package:paperless_mobile/core/widgets/dialog_utils/dialog_confirm_button.dart';
import 'package:paperless_mobile/generated/l10n/app_localizations.dart';
class ConfirmBulkModifyTagsDialog extends StatelessWidget {
final int selectionCount;
final List<String> removeTags;
final List<String> addTags;
const ConfirmBulkModifyTagsDialog({
super.key,
required this.removeTags,
required this.addTags,
required this.selectionCount,
});
@override
Widget build(BuildContext context) {
return AlertDialog(
title: Text(S.of(context)!.confirmAction),
content: RichText(
text: TextSpan(
text: _buildText(context),
children: [
const TextSpan(text: "\n\n"),
TextSpan(
text: S.of(context)!.areYouSureYouWantToContinue,
),
],
),
),
actions: const [
DialogCancelButton(),
DialogConfirmButton(
style: DialogConfirmButtonStyle.danger,
),
],
);
}
String _buildText(BuildContext context) {
if (removeTags.isNotEmpty && addTags.isNotEmpty) {
return S.of(context)!.bulkEditTagsModifyMessage(
addTags.join(", "),
selectionCount,
removeTags.join(", "),
);
} else if (removeTags.isNotEmpty) {
return S.of(context)!.bulkEditTagsRemoveMessage(
selectionCount,
removeTags.join(", "),
);
} else {
return S.of(context)!.bulkEditTagsAddMessage(
selectionCount,
addTags.join(", "),
);
}
}
}

View File

@@ -1,21 +1,23 @@
import 'package:collection/collection.dart';
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/dialog_utils/dialog_cancel_button.dart';
import 'package:paperless_mobile/core/widgets/dialog_utils/dialog_confirm_button.dart';
import 'package:paperless_mobile/core/widgets/form_fields/fullscreen_selection_form.dart';
import 'package:paperless_mobile/features/document_bulk_action/cubit/document_bulk_action_cubit.dart';
import 'package:paperless_mobile/extensions/dart_extensions.dart';
import 'package:paperless_mobile/generated/l10n/app_localizations.dart';
class FullscreenBulkEditLabelFormField extends StatefulWidget {
class FullscreenBulkEditLabelPage extends StatefulWidget {
final String hintText;
final Map<int, Label> options;
final List<DocumentModel> selection;
final int? Function(DocumentModel document) labelMapper;
final Widget leadingIcon;
final void Function(int? id) onSubmit;
final Function(String name) Function(int count) removeStringFnBuilder;
final String Function(String name) Function(int count) assignStringFnBuilder;
FullscreenBulkEditLabelFormField({
FullscreenBulkEditLabelPage({
super.key,
required this.options,
required this.selection,
@@ -23,20 +25,27 @@ class FullscreenBulkEditLabelFormField extends StatefulWidget {
required this.leadingIcon,
required this.hintText,
required this.onSubmit,
required this.removeStringFnBuilder,
required this.assignStringFnBuilder,
}) : assert(selection.isNotEmpty);
@override
State<FullscreenBulkEditLabelFormField> createState() =>
_FullscreenBulkEditLabelFormFieldState();
State<FullscreenBulkEditLabelPage> createState() =>
_FullscreenBulkEditLabelPageState();
}
class _FullscreenBulkEditLabelFormFieldState<T extends Label>
extends State<FullscreenBulkEditLabelFormField> {
class _FullscreenBulkEditLabelPageState<T extends Label>
extends State<FullscreenBulkEditLabelPage> {
final _controller = TextEditingController();
LabelSelection? _selection;
@override
void initState() {
super.initState();
_controller.addListener(() {
setState(() {});
});
if (_initialValues.length == 1 && _initialValues.first != null) {
_selection = LabelSelection(_initialValues.first);
}
@@ -46,13 +55,19 @@ class _FullscreenBulkEditLabelFormFieldState<T extends Label>
widget.selection.map(widget.labelMapper).toSet().toList();
Iterable<int> _generateOrderedLabels() sync* {
for (var label in _initialValues) {
final _availableValues = widget.options.values
.where(
(e) => e.name.normalized().contains(_controller.text.normalized()))
.map((e) => e.id!)
.toSet();
for (var label
in _initialValues.toSet().intersection(_availableValues.toSet())) {
if (label != null) {
yield label;
}
}
for (final id
in widget.options.keys.whereNot((e) => _initialValues.contains(e))) {
in _availableValues.whereNot((e) => _initialValues.contains(e))) {
yield id;
}
}
@@ -64,6 +79,7 @@ class _FullscreenBulkEditLabelFormFieldState<T extends Label>
(_initialValues.length == 1 &&
_selection?.label == _initialValues.first);
return FullscreenSelectionForm(
controller: _controller,
hintText: widget.hintText,
leadingIcon: widget.leadingIcon,
selectionBuilder: (context, index) =>
@@ -101,16 +117,10 @@ class _FullscreenBulkEditLabelFormFieldState<T extends Label>
content: Text(
S.of(context)!.areYouSureYouWantToContinue,
),
actions: [
const DialogCancelButton(),
TextButton(
onPressed: () => Navigator.of(context).pop(true),
child: Text(
S.of(context)!.confirm,
style: TextStyle(
color: Theme.of(context).colorScheme.error,
),
),
actions: const [
DialogCancelButton(),
DialogConfirmButton(
style: DialogConfirmButtonStyle.danger,
),
],
);

View File

@@ -0,0 +1,180 @@
import 'package:collection/collection.dart';
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/form_fields/fullscreen_selection_form.dart';
import 'package:paperless_mobile/extensions/dart_extensions.dart';
import 'package:paperless_mobile/features/document_bulk_action/cubit/document_bulk_action_cubit.dart';
import 'package:paperless_mobile/features/document_bulk_action/view/widgets/confirm_bulk_modify_tags_dialog.dart';
import 'package:paperless_mobile/generated/l10n/app_localizations.dart';
class FullscreenBulkEditTagsWidget extends StatefulWidget {
const FullscreenBulkEditTagsWidget({super.key});
@override
State<FullscreenBulkEditTagsWidget> createState() =>
_FullscreenBulkEditTagsWidgetState();
}
class _FullscreenBulkEditTagsWidgetState
extends State<FullscreenBulkEditTagsWidget> {
final TextEditingController _controller = TextEditingController();
/// Tags shared by all documents
late final List<int> _sharedTags;
/// Tags not assigned to at least one document in the selection
late final List<int> _nonSharedTags;
List<int> _addTags = [];
List<int> _removeTags = [];
late List<int> _filteredTags;
@override
void initState() {
super.initState();
final state = context.read<DocumentBulkActionCubit>().state;
_sharedTags = state.selection
.map((e) => e.tags)
.map((e) => e.toSet())
.fold(
state.tags.values.map((e) => e.id!).toSet(),
(previousValue, element) => previousValue.intersection(element),
)
.toList();
_nonSharedTags = state.selection
.map((e) => e.tags)
.flattened
.toSet()
.difference(_sharedTags.toSet())
.toList();
_filteredTags = state.tags.keys.toList();
_controller.addListener(() {
setState(() {
_filteredTags = context
.read<DocumentBulkActionCubit>()
.state
.tags
.values
.where((e) =>
e.name.normalized().contains(_controller.text.normalized()))
.map((e) => e.id!)
.toList();
});
});
}
List<int> get _assignedTags => [..._sharedTags, ..._nonSharedTags];
@override
Widget build(BuildContext context) {
return BlocBuilder<DocumentBulkActionCubit, DocumentBulkActionState>(
builder: (context, state) {
return FullscreenSelectionForm(
controller: _controller,
floatingActionButton: _addTags.isNotEmpty || _removeTags.isNotEmpty
? FloatingActionButton.extended(
label: Text(S.of(context)!.apply),
icon: const Icon(Icons.done),
onPressed: _submit,
)
: null,
hintText: S.of(context)!.startTyping,
leadingIcon: const Icon(Icons.label_outline),
selectionBuilder: (context, index) {
return _buildTagOption(
_filteredTags[index],
state.tags,
);
},
selectionCount: _filteredTags.length,
);
},
);
}
Widget _buildTagOption(int id, Map<int, Tag> options) {
Widget? icon;
if (_sharedTags.contains(id) && !_removeTags.contains(id)) {
// Tag is assigned to all documents and not marked for removal
// => will remain assigned
icon = const Icon(Icons.done);
} else if (_addTags.contains(id)) {
// tag is marked to be added
icon = const Icon(Icons.done);
} else if (_nonSharedTags.contains(id) && !_removeTags.contains(id)) {
// Tag is neither shared among all documents, nor marked to be removed or
// added but assigned to at least one document
icon = const Icon(Icons.remove);
}
return ListTile(
title: Text(options[id]!.name),
trailing: icon,
leading: CircleAvatar(
backgroundColor: options[id]!.color,
foregroundColor: options[id]!.textColor,
child: options[id]!.isInboxTag ? const Icon(Icons.inbox) : null,
),
onTap: () {
if (_addTags.contains(id)) {
setState(() {
_addTags.remove(id);
});
if (_assignedTags.contains(id)) {
setState(() {
_removeTags.add(id);
});
}
} else if (_removeTags.contains(id)) {
setState(() {
_removeTags.remove(id);
});
if (!_sharedTags.contains(id)) {
setState(() {
_addTags.add(id);
});
}
} else {
if (_sharedTags.contains(id)) {
setState(() {
_removeTags.add(id);
});
} else {
setState(() {
_addTags.add(id);
});
}
}
},
);
}
void _submit() async {
if (_addTags.isNotEmpty || _removeTags.isNotEmpty) {
final bloc = context.read<DocumentBulkActionCubit>();
final addNames = _addTags
.map((value) => "\"${bloc.state.tags[value]!.name}\"")
.toList();
final removeNames = _removeTags
.map((value) => "\"${bloc.state.tags[value]!.name}\"")
.toList();
final shouldPerformAction = await showDialog<bool>(
context: context,
builder: (context) => ConfirmBulkModifyTagsDialog(
selectionCount: bloc.state.selection.length,
addTags: addNames,
removeTags: removeNames,
),
) ??
false;
if (shouldPerformAction) {
bloc.bulkModifyTags(
removeTagIds: _removeTags,
addTagIds: _addTags,
);
Navigator.pop(context);
}
}
}
}