feat: Add bulk edit forms

This commit is contained in:
Anton Stubenbord
2023-03-12 18:26:44 +01:00
parent 81822f5897
commit a5df4deeb9
7 changed files with 564 additions and 305 deletions

View File

@@ -0,0 +1,185 @@
import 'dart:async';
import 'package:bloc/bloc.dart';
import 'package:collection/collection.dart';
import 'package:equatable/equatable.dart';
import 'package:paperless_api/paperless_api.dart';
import 'package:paperless_mobile/core/notifier/document_changed_notifier.dart';
import 'package:paperless_mobile/core/repository/label_repository.dart';
part 'document_bulk_action_state.dart';
class DocumentBulkActionCubit extends Cubit<DocumentBulkActionState> {
final PaperlessDocumentsApi _documentsApi;
final LabelRepository<Correspondent> _correspondentRepository;
final LabelRepository<DocumentType> _documentTypeRepository;
final LabelRepository<Tag> _tagRepository;
final LabelRepository<StoragePath> _storagePathRepository;
final DocumentChangedNotifier _notifier;
final List<StreamSubscription> _subscriptions = [];
DocumentBulkActionCubit(
this._documentsApi,
this._correspondentRepository,
this._documentTypeRepository,
this._tagRepository,
this._storagePathRepository,
this._notifier, {
required List<DocumentModel> selection,
}) : super(
DocumentBulkActionState(
selection: selection,
correspondentOptions:
(_correspondentRepository.current?.hasLoaded ?? false)
? _correspondentRepository.current!.values!
: {},
tagOptions: (_tagRepository.current?.hasLoaded ?? false)
? _tagRepository.current!.values!
: {},
documentTypeOptions:
(_documentTypeRepository.current?.hasLoaded ?? false)
? _documentTypeRepository.current!.values!
: {},
storagePathOptions:
(_storagePathRepository.current?.hasLoaded ?? false)
? _storagePathRepository.current!.values!
: {},
),
) {
_notifier.subscribe(
this,
onDeleted: (document) {
// Remove items from internal selection after the document was deleted.
emit(
state.copyWith(
selection: state.selection
.whereNot((element) => element.id == document.id)
.toList(),
),
);
},
);
_subscriptions.add(
_tagRepository.values.listen((event) {
if (event?.hasLoaded ?? false) {
emit(state.copyWith(tagOptions: event!.values));
}
}),
);
_subscriptions.add(
_correspondentRepository.values.listen((event) {
if (event?.hasLoaded ?? false) {
emit(state.copyWith(
correspondentOptions: event!.values,
));
}
}),
);
_subscriptions.add(
_documentTypeRepository.values.listen((event) {
if (event?.hasLoaded ?? false) {
emit(state.copyWith(documentTypeOptions: event!.values));
}
}),
);
_subscriptions.add(
_storagePathRepository.values.listen((event) {
if (event?.hasLoaded ?? false) {
emit(state.copyWith(storagePathOptions: event!.values));
}
}),
);
}
Future<void> bulkDelete() async {
final deletedDocumentIds = await _documentsApi.bulkAction(
BulkDeleteAction(state.selection.map((e) => e.id).toList()),
);
final deletedDocuments = state.selection
.where((element) => deletedDocumentIds.contains(element.id));
for (final doc in deletedDocuments) {
_notifier.notifyUpdated(doc);
}
}
Future<void> bulkModifyCorrespondent(int? correspondentId) async {
final modifiedDocumentIds = await _documentsApi.bulkAction(
BulkModifyLabelAction.correspondent(
state.selectedIds,
labelId: correspondentId,
),
);
final updatedDocuments = state.selection
.where((element) => modifiedDocumentIds.contains(element.id))
.map((doc) => doc.copyWith(correspondent: () => correspondentId));
for (final doc in updatedDocuments) {
_notifier.notifyUpdated(doc);
}
}
Future<void> bulkModifyDocumentType(int? documentTypeId) async {
final modifiedDocumentIds = await _documentsApi.bulkAction(
BulkModifyLabelAction.documentType(
state.selectedIds,
labelId: documentTypeId,
),
);
final updatedDocuments = state.selection
.where((element) => modifiedDocumentIds.contains(element.id))
.map((doc) => doc.copyWith(documentType: () => documentTypeId));
for (final doc in updatedDocuments) {
_notifier.notifyUpdated(doc);
}
}
Future<void> bulkModifyStoragePath(int? storagePathId) async {
final modifiedDocumentIds = await _documentsApi.bulkAction(
BulkModifyLabelAction.storagePath(
state.selectedIds,
labelId: storagePathId,
),
);
final updatedDocuments = state.selection
.where((element) => modifiedDocumentIds.contains(element.id))
.map((doc) => doc.copyWith(storagePath: () => storagePathId));
for (final doc in updatedDocuments) {
_notifier.notifyUpdated(doc);
}
}
Future<void> bulkModifyTags({
Iterable<int> addTagIds = const [],
Iterable<int> removeTagIds = const [],
}) async {
final modifiedDocumentIds = await _documentsApi.bulkAction(
BulkModifyTagsAction(
state.selectedIds,
addTags: addTagIds,
removeTags: removeTagIds,
),
);
final updatedDocuments = state.selection
.where((element) => modifiedDocumentIds.contains(element.id))
.map(
(doc) => doc.copyWith(
tags: [
...doc.tags.toSet().difference(addTagIds.toSet()),
...addTagIds
],
),
);
for (final doc in updatedDocuments) {
_notifier.notifyUpdated(doc);
}
}
@override
Future<void> close() {
_notifier.unsubscribe(this);
for (final sub in _subscriptions) {
sub.cancel();
}
return super.close();
}
}

View File

@@ -0,0 +1,44 @@
part of 'document_bulk_action_cubit.dart';
class DocumentBulkActionState extends Equatable {
final List<DocumentModel> selection;
final Map<int, Correspondent> correspondentOptions;
final Map<int, DocumentType> documentTypeOptions;
final Map<int, Tag> tagOptions;
final Map<int, StoragePath> storagePathOptions;
const DocumentBulkActionState({
this.correspondentOptions = const {},
this.documentTypeOptions = const {},
this.tagOptions = const {},
this.storagePathOptions = const {},
this.selection = const [],
});
@override
List<Object> get props => [
selection,
correspondentOptions,
documentTypeOptions,
tagOptions,
storagePathOptions,
];
Iterable<int> get selectedIds => selection.map((d) => d.id);
DocumentBulkActionState copyWith({
List<DocumentModel>? selection,
Map<int, Correspondent>? correspondentOptions,
Map<int, DocumentType>? documentTypeOptions,
Map<int, Tag>? tagOptions,
Map<int, StoragePath>? storagePathOptions,
}) {
return DocumentBulkActionState(
selection: selection ?? this.selection,
correspondentOptions: correspondentOptions ?? this.correspondentOptions,
documentTypeOptions: documentTypeOptions ?? this.documentTypeOptions,
storagePathOptions: storagePathOptions ?? this.storagePathOptions,
tagOptions: tagOptions ?? this.tagOptions,
);
}
}

View File

@@ -0,0 +1,98 @@
import 'package:flutter/material.dart';
import 'package:flutter/src/widgets/framework.dart';
import 'package:flutter/src/widgets/placeholder.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:flutter_form_builder/flutter_form_builder.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/features/labels/view/widgets/label_form_field.dart';
import 'package:paperless_mobile/generated/l10n/app_localizations.dart';
class BulkEditLabelBottomSheet<T extends Label> extends StatefulWidget {
final String title;
final String formFieldLabel;
final Widget formFieldPrefixIcon;
final Map<int, T> Function(DocumentBulkActionState state)
availableOptionsSelector;
final void Function(int? selectedId) onSubmit;
final int? initialValue;
const BulkEditLabelBottomSheet({
super.key,
required this.title,
required this.formFieldLabel,
required this.formFieldPrefixIcon,
required this.availableOptionsSelector,
required this.onSubmit,
this.initialValue,
});
@override
State<BulkEditLabelBottomSheet<T>> createState() =>
_BulkEditLabelBottomSheetState<T>();
}
class _BulkEditLabelBottomSheetState<T extends Label>
extends State<BulkEditLabelBottomSheet<T>> {
final _formKey = GlobalKey<FormBuilderState>();
@override
Widget build(BuildContext context) {
return Padding(
padding:
EdgeInsets.only(bottom: MediaQuery.of(context).viewInsets.bottom),
child: BlocBuilder<DocumentBulkActionCubit, DocumentBulkActionState>(
builder: (context, state) {
return Padding(
padding: const EdgeInsets.all(16.0),
child: SingleChildScrollView(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
Text(
widget.title,
style: Theme.of(context).textTheme.titleLarge,
).paddedOnly(bottom: 24),
FormBuilder(
key: _formKey,
child: LabelFormField<T>(
initialValue:
IdQueryParameter.fromId(widget.initialValue),
name: "labelFormField",
labelOptions: widget.availableOptionsSelector(state),
textFieldLabel: widget.formFieldLabel,
formBuilderState: _formKey.currentState,
prefixIcon: widget.formFieldPrefixIcon,
),
),
const SizedBox(height: 8),
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 IdQueryParameter?;
widget.onSubmit(value?.id);
}
},
child: Text(S.of(context)!.apply),
),
],
).padded(8),
],
),
),
);
},
),
);
}
}

View File

@@ -0,0 +1,109 @@
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: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/features/labels/tags/view/widgets/tag_widget.dart';
import 'package:paperless_mobile/features/labels/tags/view/widgets/tags_form_field.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>();
List<int> _tagsToRemove = [];
List<int> _tagsToAdd = [];
@override
Widget build(BuildContext context) {
return BlocBuilder<DocumentBulkActionCubit, DocumentBulkActionState>(
builder: (context, state) {
final sharedTags = state.selection
.map((doc) => doc.tags)
.reduce((previousValue, element) =>
previousValue.toSet().intersection(element.toSet()))
.toList();
final nonSharedTags = state.selection
.map((doc) => doc.tags)
.flattened
.toSet()
.difference(sharedTags.toSet())
.toList();
return Padding(
padding:
EdgeInsets.only(bottom: MediaQuery.of(context).viewInsets.bottom),
child: BlocBuilder<DocumentBulkActionCubit, DocumentBulkActionState>(
builder: (context, 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),
FormBuilder(
key: _formKey,
child: TagFormField(
initialValue: IdsTagsQuery(
sharedTags.map((tag) => IncludeTagIdQuery(tag)),
),
name: "labelFormField",
selectableOptions: state.tagOptions,
allowCreation: false,
anyAssignedSelectable: false,
excludeAllowed: false,
),
),
const SizedBox(height: 8),
Text("Tags removed after apply"),
Wrap(),
const SizedBox(height: 8),
Text("Tags added after apply"),
Wrap(),
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),
],
),
),
);
},
),
);
},
);
}
}