diff --git a/lib/features/document_edit/view/document_edit_page.dart b/lib/features/document_edit/view/document_edit_page.dart index 4c5687c..81cf859 100644 --- a/lib/features/document_edit/view/document_edit_page.dart +++ b/lib/features/document_edit/view/document_edit_page.dart @@ -196,7 +196,7 @@ class _DocumentEditPageState extends State { formBuilderState: _formKey.currentState, labelCreationWidgetBuilder: (initialValue) => RepositoryProvider( create: (context) => context.read>(), - child: AddStoragePathPage(initalValue: initialValue), + child: AddStoragePathPage(initalName: initialValue), ), textFieldLabel: S.of(context)!.storagePath, labelOptions: options, diff --git a/lib/features/document_search/view/sliver_search_bar.dart b/lib/features/document_search/view/sliver_search_bar.dart index 706bbe8..efba880 100644 --- a/lib/features/document_search/view/sliver_search_bar.dart +++ b/lib/features/document_search/view/sliver_search_bar.dart @@ -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( diff --git a/lib/features/documents/cubit/documents_cubit.dart b/lib/features/documents/cubit/documents_cubit.dart index a9aae8d..7c1ca2b 100644 --- a/lib/features/documents/cubit/documents_cubit.dart +++ b/lib/features/documents/cubit/documents_cubit.dart @@ -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 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 Iterable 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 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) { diff --git a/lib/features/documents/view/pages/documents_page.dart b/lib/features/documents/view/pages/documents_page.dart index 3a455fa..103c87c 100644 --- a/lib/features/documents/view/pages/documents_page.dart +++ b/lib/features/documents/view/pages/documents_page.dart @@ -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 if (state.selection.isNotEmpty) { // Show selection app bar when selection mode is active return DocumentSelectionSliverAppBar( - state: state); + state: state, + ); } return const SliverSearchBar(floating: true); }, diff --git a/lib/features/documents/view/widgets/search/document_filter_form.dart b/lib/features/documents/view/widgets/search/document_filter_form.dart index 4b05e97..8bb5937 100644 --- a/lib/features/documents/view/widgets/search/document_filter_form.dart +++ b/lib/features/documents/view/widgets/search/document_filter_form.dart @@ -80,7 +80,7 @@ class _DocumentFilterFormState extends State { slivers: [ if (widget.header != null) widget.header!, ..._buildFormFieldList(), - SliverToBoxAdapter( + const SliverToBoxAdapter( child: SizedBox( height: 32, ), diff --git a/lib/features/documents/view/widgets/selection/document_selection_sliver_app_bar.dart b/lib/features/documents/view/widgets/selection/document_selection_sliver_app_bar.dart index 4ee5265..c69da93 100644 --- a/lib/features/documents/view/widgets/selection/document_selection_sliver_app_bar.dart +++ b/lib/features/documents/view/widgets/selection/document_selection_sliver_app_bar.dart @@ -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(); + 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, + LabelState>( + builder: (context, state) { + return LabelFormField( + 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().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(); + 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, + LabelState>( + builder: (context, state) { + return LabelFormField( + 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().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(); + 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, + LabelState>( + builder: (context, state) { + return LabelFormField( + 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().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(); + 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, + LabelState>( + builder: (context, state) { + return LabelFormField( + 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().bulkAction, + title: 'Bulk edit storage path', + ); + }, + ); + }, + ); + } +} + +class BulkEditBottomSheet extends StatefulWidget { + final Future Function(BulkAction action) onQuerySubmitted; + final List selectedIds; + final IdQueryParameter initialValue; + final String title; + final Widget formField; + final String formFieldName; + final BulkAction Function(int? id) actionBuilder; + final GlobalKey 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 createState() => _BulkEditBottomSheetState(); +} + +class _BulkEditBottomSheetState extends State { + @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), + ), + ], + ), ); } } diff --git a/lib/features/edit_label/view/impl/add_storage_path_page.dart b/lib/features/edit_label/view/impl/add_storage_path_page.dart index c192588..e306711 100644 --- a/lib/features/edit_label/view/impl/add_storage_path_page.dart +++ b/lib/features/edit_label/view/impl/add_storage_path_page.dart @@ -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( pageTitle: Text(S.of(context)!.addStoragePath), fromJsonT: StoragePath.fromJson, - initialName: initalValue, + initialName: initalName, additionalFields: const [ StoragePathAutofillFormBuilderField(name: StoragePath.pathKey), SizedBox(height: 120.0), diff --git a/packages/paperless_api/lib/src/models/bulk_edit_model.dart b/packages/paperless_api/lib/src/models/bulk_edit_model.dart index e04300f..23b5ad0 100644 --- a/packages/paperless_api/lib/src/models/bulk_edit_model.dart +++ b/packages/paperless_api/lib/src/models/bulk_edit_model.dart @@ -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 toJson() { + return { + 'documents': documentIds.toList(), + 'method': 'set_$_labelName', + 'parameters': { + _labelName: labelId, + } + }; + } +}