feat: Add bulk edit options (WIP)

This commit is contained in:
Anton Stubenbord
2023-03-11 18:39:27 +01:00
parent c7b5298845
commit 81822f5897
8 changed files with 431 additions and 17 deletions

View File

@@ -196,7 +196,7 @@ class _DocumentEditPageState extends State<DocumentEditPage> {
formBuilderState: _formKey.currentState,
labelCreationWidgetBuilder: (initialValue) => RepositoryProvider(
create: (context) => context.read<LabelRepository<StoragePath>>(),
child: AddStoragePathPage(initalValue: initialValue),
child: AddStoragePathPage(initalName: initialValue),
),
textFieldLabel: S.of(context)!.storagePath,
labelOptions: options,

View File

@@ -23,12 +23,12 @@ class SliverSearchBar extends StatelessWidget {
floating: floating,
pinned: pinned,
delegate: CustomizableSliverPersistentHeaderDelegate(
minExtent: 56 + 8,
maxExtent: 56 + 8,
child: Padding(
padding: const EdgeInsets.all(8.0),
minExtent: kToolbarHeight + 8,
maxExtent: kToolbarHeight + 8,
child: Container(
margin: const EdgeInsets.symmetric(horizontal: 8.0),
child: SearchBar(
height: 56,
height: kToolbarHeight,
supportingText: S.of(context)!.searchDocuments,
onTap: () => showDocumentSearchPage(context),
leadingIcon: IconButton(

View File

@@ -1,6 +1,7 @@
import 'dart:async';
import 'dart:developer';
import 'package:collection/collection.dart';
import 'package:flutter/foundation.dart';
import 'package:hydrated_bloc/hydrated_bloc.dart';
import 'package:paperless_api/paperless_api.dart';
@@ -24,8 +25,25 @@ class DocumentsCubit extends HydratedCubit<DocumentsState>
DocumentsCubit(this.api, this.notifier) : super(const DocumentsState()) {
notifier.subscribe(
this,
onUpdated: replace,
onDeleted: remove,
onUpdated: (document) {
replace(document);
emit(
state.copyWith(
selection: state.selection
.map((e) => e.id == document.id ? document : e)
.toList(),
),
);
},
onDeleted: (document) {
remove(document);
emit(
state.copyWith(
selection:
state.selection.where((e) => e.id != document.id).toList(),
),
);
},
);
}
@@ -46,12 +64,35 @@ class DocumentsCubit extends HydratedCubit<DocumentsState>
Iterable<int> removeTags = const [],
}) async {
debugPrint("[DocumentsCubit] bulkEditTags");
await api.bulkAction(BulkModifyTagsAction(
final edited = await api.bulkAction(BulkModifyTagsAction(
documents.map((doc) => doc.id),
addTags: addTags,
removeTags: removeTags,
));
await reload();
for (final id in edited) {
final doc =
state.documents.firstWhereOrNull((element) => element.id == id);
if (doc != null) {
notifier.notifyUpdated(doc);
}
}
}
Future<void> bulkAction(BulkAction action) async {
debugPrint("[DocumentsCubit] bulkEditLabel");
final edited = await api.bulkAction(action);
await reload();
for (final id in edited) {
final doc =
state.documents.firstWhereOrNull((element) => element.id == id);
if (doc != null) {
notifier.notifyUpdated(doc);
}
}
}
void toggleDocumentSelection(DocumentModel model) {

View File

@@ -7,7 +7,6 @@ import 'package:paperless_mobile/core/delegate/customizable_sliver_persistent_he
import 'package:paperless_mobile/core/widgets/material/search/colored_tab_bar.dart';
import 'package:paperless_mobile/extensions/flutter_extensions.dart';
import 'package:paperless_mobile/features/app_drawer/view/app_drawer.dart';
import 'package:paperless_mobile/features/document_details/view/pages/document_details_page.dart';
import 'package:paperless_mobile/features/document_search/view/sliver_search_bar.dart';
import 'package:paperless_mobile/features/documents/cubit/documents_cubit.dart';
import 'package:paperless_mobile/features/documents/view/widgets/adaptive_documents_view.dart';
@@ -167,7 +166,8 @@ class _DocumentsPageState extends State<DocumentsPage>
if (state.selection.isNotEmpty) {
// Show selection app bar when selection mode is active
return DocumentSelectionSliverAppBar(
state: state);
state: state,
);
}
return const SliverSearchBar(floating: true);
},

View File

@@ -80,7 +80,7 @@ class _DocumentFilterFormState extends State<DocumentFilterForm> {
slivers: [
if (widget.header != null) widget.header!,
..._buildFormFieldList(),
SliverToBoxAdapter(
const SliverToBoxAdapter(
child: SizedBox(
height: 32,
),

View File

@@ -1,9 +1,20 @@
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/documents/cubit/documents_cubit.dart';
import 'package:paperless_mobile/features/documents/view/widgets/selection/bulk_delete_confirmation_dialog.dart';
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/cubit/label_cubit.dart';
import 'package:paperless_mobile/features/labels/cubit/providers/correspondent_bloc_provider.dart';
import 'package:paperless_mobile/features/labels/cubit/providers/document_type_bloc_provider.dart';
import 'package:paperless_mobile/features/labels/cubit/providers/labels_bloc_provider.dart';
import 'package:paperless_mobile/features/labels/cubit/providers/storage_path_bloc_provider.dart';
import 'package:paperless_mobile/features/labels/view/widgets/label_form_field.dart';
import 'package:paperless_mobile/generated/l10n/app_localizations.dart';
import 'package:paperless_mobile/helpers/message_helpers.dart';
import 'package:provider/provider.dart';
@@ -50,6 +61,337 @@ class DocumentSelectionSliverAppBar extends StatelessWidget {
},
),
],
bottom: PreferredSize(
preferredSize: Size.fromHeight(kTextTabBarHeight),
child: SizedBox(
height: kTextTabBarHeight,
child: ListView(
scrollDirection: Axis.horizontal,
children: [
_buildBulkEditCorrespondentChip(context)
.paddedOnly(left: 8, right: 8),
_buildBulkEditDocumentTypeChip(context).paddedOnly(right: 8),
_buildBulkEditTagChip(context).paddedOnly(right: 8),
_buildBulkEditStoragePathChip(context).paddedOnly(right: 8),
],
),
),
),
);
}
Widget _buildBulkEditCorrespondentChip(BuildContext context) {
return ActionChip(
label: Text(S.of(context)!.correspondent),
avatar: Icon(Icons.edit),
onPressed: () {
final _formKey = GlobalKey<FormBuilderState>();
final initialValue = state.selection.every((element) =>
element.correspondent == state.selection.first.correspondent)
? IdQueryParameter.fromId(state.selection.first.correspondent)
: const IdQueryParameter.unset();
showModalBottomSheet(
shape: const RoundedRectangleBorder(
borderRadius: BorderRadius.only(
topLeft: Radius.circular(16),
topRight: Radius.circular(16),
),
),
isScrollControlled: true,
context: context,
builder: (_) {
return BulkEditBottomSheet(
formKey: _formKey,
formFieldName: "correspondent",
initialValue: initialValue,
selectedIds: state.selectedIds,
actionBuilder: (int? id) => BulkModifyLabelAction.correspondent(
state.selectedIds,
labelId: id,
),
formField: CorrespondentBlocProvider(
child: BlocBuilder<LabelCubit<Correspondent>,
LabelState<Correspondent>>(
builder: (context, state) {
return LabelFormField<Correspondent>(
name: "correspondent",
initialValue: initialValue,
notAssignedSelectable: false,
labelCreationWidgetBuilder: (initialName) {
return AddCorrespondentPage(
initialName: initialName,
);
},
labelOptions: state.labels,
textFieldLabel: "Correspondent",
formBuilderState: _formKey.currentState,
prefixIcon: const Icon(Icons.person),
).padded();
},
),
),
onQuerySubmitted: context.read<DocumentsCubit>().bulkAction,
title: 'Bulk edit correspondent',
);
},
);
},
);
}
Widget _buildBulkEditDocumentTypeChip(BuildContext context) {
return ActionChip(
label: Text(S.of(context)!.documentType),
avatar: Icon(Icons.edit),
onPressed: () {
final _formKey = GlobalKey<FormBuilderState>();
final initialValue = state.selection.every((element) =>
element.documentType == state.selection.first.documentType)
? IdQueryParameter.fromId(state.selection.first.documentType)
: const IdQueryParameter.unset();
showModalBottomSheet(
shape: const RoundedRectangleBorder(
borderRadius: BorderRadius.only(
topLeft: Radius.circular(16),
topRight: Radius.circular(16),
),
),
isScrollControlled: true,
context: context,
builder: (_) {
return BulkEditBottomSheet(
formKey: _formKey,
formFieldName: "documentType",
initialValue: initialValue,
selectedIds: state.selectedIds,
actionBuilder: (int? id) => BulkModifyLabelAction.documentType(
state.selectedIds,
labelId: id,
),
formField: DocumentTypeBlocProvider(
child: BlocBuilder<LabelCubit<DocumentType>,
LabelState<DocumentType>>(
builder: (context, state) {
return LabelFormField<DocumentType>(
name: "documentType",
initialValue: initialValue,
notAssignedSelectable: false,
labelCreationWidgetBuilder: (initialName) {
return AddDocumentTypePage(
initialName: initialName,
);
},
labelOptions: state.labels,
textFieldLabel: S.of(context)!.documentType,
formBuilderState: _formKey.currentState,
prefixIcon: const Icon(Icons.person),
).padded();
},
),
),
onQuerySubmitted: context.read<DocumentsCubit>().bulkAction,
title: 'Bulk edit document type',
);
},
);
},
);
}
Widget _buildBulkEditTagChip(BuildContext context) {
return ActionChip(
label: Text(S.of(context)!.correspondent),
avatar: Icon(Icons.edit),
onPressed: () {
final _formKey = GlobalKey<FormBuilderState>();
final initialValue = state.selection.every((element) =>
element.correspondent == state.selection.first.correspondent)
? IdQueryParameter.fromId(state.selection.first.correspondent)
: const IdQueryParameter.unset();
showModalBottomSheet(
shape: const RoundedRectangleBorder(
borderRadius: BorderRadius.only(
topLeft: Radius.circular(16),
topRight: Radius.circular(16),
),
),
isScrollControlled: true,
context: context,
builder: (_) {
return BulkEditBottomSheet(
formKey: _formKey,
formFieldName: "correspondent",
initialValue: initialValue,
selectedIds: state.selectedIds,
actionBuilder: (int? id) => BulkModifyLabelAction.correspondent(
state.selectedIds,
labelId: id,
),
formField: CorrespondentBlocProvider(
child: BlocBuilder<LabelCubit<Correspondent>,
LabelState<Correspondent>>(
builder: (context, state) {
return LabelFormField<Correspondent>(
name: "correspondent",
initialValue: initialValue,
notAssignedSelectable: false,
labelCreationWidgetBuilder: (initialName) {
return AddCorrespondentPage(
initialName: initialName,
);
},
labelOptions: state.labels,
textFieldLabel: "Correspondent",
formBuilderState: _formKey.currentState,
prefixIcon: const Icon(Icons.person),
).padded();
},
),
),
onQuerySubmitted: context.read<DocumentsCubit>().bulkAction,
title: 'Bulk edit correspondent',
);
},
);
},
);
}
Widget _buildBulkEditStoragePathChip(BuildContext context) {
return ActionChip(
label: Text(S.of(context)!.storagePath),
avatar: Icon(Icons.edit),
onPressed: () {
final _formKey = GlobalKey<FormBuilderState>();
final initialValue = state.selection.every((element) =>
element.storagePath == state.selection.first.storagePath)
? IdQueryParameter.fromId(state.selection.first.storagePath)
: const IdQueryParameter.unset();
showModalBottomSheet(
shape: const RoundedRectangleBorder(
borderRadius: BorderRadius.only(
topLeft: Radius.circular(16),
topRight: Radius.circular(16),
),
),
isScrollControlled: true,
context: context,
builder: (_) {
return BulkEditBottomSheet(
formKey: _formKey,
formFieldName: "storagePath",
initialValue: initialValue,
selectedIds: state.selectedIds,
actionBuilder: (int? id) => BulkModifyLabelAction.storagePath(
state.selectedIds,
labelId: id,
),
formField: StoragePathBlocProvider(
child: BlocBuilder<LabelCubit<StoragePath>,
LabelState<StoragePath>>(
builder: (context, state) {
return LabelFormField<StoragePath>(
name: "storagePath",
initialValue: initialValue,
notAssignedSelectable: false,
labelCreationWidgetBuilder: (initialName) {
return AddStoragePathPage(
initalName: initialName,
);
},
labelOptions: state.labels,
textFieldLabel: S.of(context)!.storagePath,
formBuilderState: _formKey.currentState,
prefixIcon: const Icon(Icons.person),
).padded();
},
),
),
onQuerySubmitted: context.read<DocumentsCubit>().bulkAction,
title: 'Bulk edit storage path',
);
},
);
},
);
}
}
class BulkEditBottomSheet extends StatefulWidget {
final Future<void> Function(BulkAction action) onQuerySubmitted;
final List<int> selectedIds;
final IdQueryParameter initialValue;
final String title;
final Widget formField;
final String formFieldName;
final BulkAction Function(int? id) actionBuilder;
final GlobalKey<FormBuilderState> formKey;
const BulkEditBottomSheet({
super.key,
required this.initialValue,
required this.onQuerySubmitted,
required this.selectedIds,
required this.title,
required this.formField,
required this.formFieldName,
required this.actionBuilder,
required this.formKey,
});
@override
State<BulkEditBottomSheet> createState() => _BulkEditBottomSheetState();
}
class _BulkEditBottomSheetState extends State<BulkEditBottomSheet> {
@override
Widget build(BuildContext context) {
return Padding(
padding: EdgeInsets.only(
bottom: MediaQuery.of(context).viewInsets.bottom,
),
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
widget.title,
style: Theme.of(context).textTheme.headlineSmall,
).padded(16),
FormBuilder(
key: widget.formKey,
child: widget.formField,
),
Align(
alignment: Alignment.bottomRight,
child: Row(
mainAxisAlignment: MainAxisAlignment.end,
children: [
const DialogCancelButton().paddedOnly(right: 8),
FilledButton(
child: Text(S.of(context)!.apply),
onPressed: () async {
if (widget.formKey.currentState?.saveAndValidate() ??
false) {
final value = widget
.formKey
.currentState!
.fields[widget.formFieldName]
?.value as IdQueryParameter;
final id = value.id;
await widget.onQuerySubmitted(widget.actionBuilder(id));
Navigator.of(context).pop();
showSnackBar(
context,
"Documents successfully edited.",
);
}
},
),
],
).padded(16),
),
],
),
);
}
}

View File

@@ -9,8 +9,8 @@ import 'package:paperless_mobile/features/labels/storage_path/view/widgets/stora
import 'package:paperless_mobile/generated/l10n/app_localizations.dart';
class AddStoragePathPage extends StatelessWidget {
final String? initalValue;
const AddStoragePathPage({Key? key, this.initalValue}) : super(key: key);
final String? initalName;
const AddStoragePathPage({Key? key, this.initalName}) : super(key: key);
@override
Widget build(BuildContext context) {
@@ -21,7 +21,7 @@ class AddStoragePathPage extends StatelessWidget {
child: AddLabelPage<StoragePath>(
pageTitle: Text(S.of(context)!.addStoragePath),
fromJsonT: StoragePath.fromJson,
initialName: initalValue,
initialName: initalName,
additionalFields: const [
StoragePathAutofillFormBuilderField(name: StoragePath.pathKey),
SizedBox(height: 120.0),

View File

@@ -48,3 +48,34 @@ class BulkModifyTagsAction extends BulkAction {
};
}
}
class BulkModifyLabelAction extends BulkAction {
final String _labelName;
final int? labelId;
BulkModifyLabelAction.correspondent(
super.documents, {
required this.labelId,
}) : _labelName = 'correspondent';
BulkModifyLabelAction.documentType(
super.documents, {
required this.labelId,
}) : _labelName = 'document_type';
BulkModifyLabelAction.storagePath(
super.documents, {
required this.labelId,
}) : _labelName = 'storage_path';
@override
Map<String, dynamic> toJson() {
return {
'documents': documentIds.toList(),
'method': 'set_$_labelName',
'parameters': {
_labelName: labelId,
}
};
}
}