mirror of
https://github.com/Xevion/paperless-mobile.git
synced 2025-12-06 09:15:48 -06:00
feat: Add bulk edit options (WIP)
This commit is contained in:
@@ -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,
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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);
|
||||
},
|
||||
|
||||
@@ -80,7 +80,7 @@ class _DocumentFilterFormState extends State<DocumentFilterForm> {
|
||||
slivers: [
|
||||
if (widget.header != null) widget.header!,
|
||||
..._buildFormFieldList(),
|
||||
SliverToBoxAdapter(
|
||||
const SliverToBoxAdapter(
|
||||
child: SizedBox(
|
||||
height: 32,
|
||||
),
|
||||
|
||||
@@ -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),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user