diff --git a/lib/features/app_drawer/view/app_drawer.dart b/lib/features/app_drawer/view/app_drawer.dart new file mode 100644 index 0000000..e69de29 diff --git a/lib/features/document_details/view/pages/similar_documents_view.dart b/lib/features/document_details/view/pages/similar_documents_view.dart index 2fb2b53..01c5fa2 100644 --- a/lib/features/document_details/view/pages/similar_documents_view.dart +++ b/lib/features/document_details/view/pages/similar_documents_view.dart @@ -1,10 +1,12 @@ 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/documents_list_loading_widget.dart'; +import 'package:paperless_mobile/core/bloc/connectivity_cubit.dart'; +import 'package:paperless_mobile/features/documents/view/widgets/adaptive_documents_view.dart'; +import 'package:paperless_mobile/features/documents/view/widgets/documents_list_loading_widget.dart'; import 'package:paperless_mobile/core/widgets/hint_card.dart'; import 'package:paperless_mobile/features/documents/view/widgets/documents_empty_state.dart'; -import 'package:paperless_mobile/features/documents/view/widgets/list/document_list_item.dart'; +import 'package:paperless_mobile/features/documents/view/widgets/items/document_list_item.dart'; import 'package:paperless_mobile/features/similar_documents/cubit/similar_documents_cubit.dart'; import 'package:paperless_mobile/helpers/message_helpers.dart'; import 'package:paperless_mobile/constants.dart'; @@ -58,11 +60,6 @@ class _SimilarDocumentsViewState extends State { ); return BlocBuilder( builder: (context, state) { - if (!state.hasLoaded) { - return const DocumentsListLoadingWidget( - beforeWidgets: [earlyPreviewHintCard], - ); - } if (state.documents.isEmpty) { return DocumentsEmptyState( state: state, @@ -74,20 +71,36 @@ class _SimilarDocumentsViewState extends State { ), ); } - return CustomScrollView( - controller: _scrollController, - slivers: [ - const SliverToBoxAdapter(child: earlyPreviewHintCard), - SliverList( - delegate: SliverChildBuilderDelegate( - childCount: state.documents.length, - (context, index) => DocumentListItem( - document: state.documents[index], - enableHeroAnimation: false, + + return BlocBuilder( + builder: (context, connectivity) { + return CustomScrollView( + controller: _scrollController, + slivers: [ + const SliverToBoxAdapter(child: earlyPreviewHintCard), + SliverAdaptiveDocumentsView( + documents: state.documents, + hasInternetConnection: connectivity.isConnected, + isLabelClickable: false, + isLoading: state.isLoading, + hasLoaded: state.hasLoaded, + ), - ), - ), - ], + SliverList( + delegate: SliverChildBuilderDelegate( + childCount: state.documents.length, + (context, index) => DocumentListItem( + document: state.documents[index], + enableHeroAnimation: false, + isLabelClickable: false, + isSelected: false, + isSelectionActive: false, + ), + ), + ), + ], + ); + }, ); }, ); diff --git a/lib/features/documents/bloc/documents_cubit.dart b/lib/features/documents/bloc/documents_cubit.dart index 428fb91..eefd2c6 100644 --- a/lib/features/documents/bloc/documents_cubit.dart +++ b/lib/features/documents/bloc/documents_cubit.dart @@ -66,38 +66,11 @@ class DocumentsCubit extends HydratedCubit emit(const DocumentsState()); } - Future selectView(int id) async { - emit(state.copyWith(isLoading: true)); - try { - final filter = - _savedViewRepository.current?.values[id]?.toDocumentFilter(); - if (filter == null) { - return; - } - final results = await api.findAll(filter.copyWith(page: 1)); - emit( - DocumentsState( - filter: filter, - hasLoaded: true, - isLoading: false, - selectedSavedViewId: id, - value: [results], - ), - ); - } finally { - emit(state.copyWith(isLoading: false)); - } - } - Future> autocomplete(String query) async { final res = await api.autocomplete(query); return res; } - void unselectView() { - emit(state.copyWith(selectedSavedViewId: () => null)); - } - @override DocumentsState? fromJson(Map json) { return DocumentsState.fromJson(json); diff --git a/lib/features/documents/bloc/documents_state.dart b/lib/features/documents/bloc/documents_state.dart index 23adc83..1e080a5 100644 --- a/lib/features/documents/bloc/documents_state.dart +++ b/lib/features/documents/bloc/documents_state.dart @@ -3,14 +3,11 @@ import 'package:paperless_api/paperless_api.dart'; import 'package:paperless_mobile/features/paged_document_view/model/paged_documents_state.dart'; class DocumentsState extends PagedDocumentsState { - final int? selectedSavedViewId; - @JsonKey(ignore: true) final List selection; const DocumentsState({ this.selection = const [], - this.selectedSavedViewId, super.value = const [], super.filter = const DocumentFilter(), super.hasLoaded = false, @@ -25,7 +22,6 @@ class DocumentsState extends PagedDocumentsState { List>? value, DocumentFilter? filter, List? selection, - int? Function()? selectedSavedViewId, }) { return DocumentsState( hasLoaded: hasLoaded ?? this.hasLoaded, @@ -33,9 +29,6 @@ class DocumentsState extends PagedDocumentsState { value: value ?? this.value, filter: filter ?? this.filter, selection: selection ?? this.selection, - selectedSavedViewId: selectedSavedViewId != null - ? selectedSavedViewId.call() - : this.selectedSavedViewId, ); } @@ -46,7 +39,6 @@ class DocumentsState extends PagedDocumentsState { value, selection, isLoading, - selectedSavedViewId, ]; Map toJson() { @@ -54,7 +46,6 @@ class DocumentsState extends PagedDocumentsState { 'hasLoaded': hasLoaded, 'isLoading': isLoading, 'filter': filter.toJson(), - 'selectedSavedViewId': selectedSavedViewId, 'value': value.map((e) => e.toJson(DocumentModelJsonConverter())).toList(), }; @@ -65,7 +56,6 @@ class DocumentsState extends PagedDocumentsState { return DocumentsState( hasLoaded: json['hasLoaded'], isLoading: json['isLoading'], - selectedSavedViewId: json['selectedSavedViewId'], value: (json['value'] as List) .map((e) => PagedSearchResult.fromJsonT(e, DocumentModelJsonConverter())) diff --git a/lib/features/documents/view/pages/documents_page.dart b/lib/features/documents/view/pages/documents_page.dart index 0777db5..8f8c7ef 100644 --- a/lib/features/documents/view/pages/documents_page.dart +++ b/lib/features/documents/view/pages/documents_page.dart @@ -1,3 +1,5 @@ +import 'dart:developer'; + import 'package:badges/badges.dart' as b; import 'package:collection/collection.dart'; import 'package:flutter/material.dart'; @@ -5,35 +7,26 @@ import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:paperless_api/paperless_api.dart'; import 'package:paperless_mobile/core/bloc/connectivity_cubit.dart'; import 'package:paperless_mobile/core/repository/provider/label_repositories_provider.dart'; -import 'package:paperless_mobile/core/translation/sort_field_localization_mapper.dart'; -import 'package:paperless_mobile/core/widgets/app_options_popup_menu.dart'; -import 'package:paperless_mobile/core/widgets/material/search/m3_search.dart'; -import 'package:paperless_mobile/core/widgets/material/search/m3_search_bar.dart'; import 'package:paperless_mobile/features/document_details/bloc/document_details_cubit.dart'; import 'package:paperless_mobile/features/document_details/view/pages/document_details_page.dart'; -import 'package:paperless_mobile/features/document_search/cubit/document_search_cubit.dart'; -import 'package:paperless_mobile/features/document_search/document_search_delegate.dart'; -import 'package:paperless_mobile/features/document_search/view/document_search_bar.dart'; import 'package:paperless_mobile/features/documents/bloc/documents_cubit.dart'; import 'package:paperless_mobile/features/documents/bloc/documents_state.dart'; +import 'package:paperless_mobile/features/documents/view/widgets/adaptive_documents_view.dart'; import 'package:paperless_mobile/features/documents/view/widgets/documents_empty_state.dart'; -import 'package:paperless_mobile/features/documents/view/widgets/list/adaptive_documents_view.dart'; import 'package:paperless_mobile/features/documents/view/widgets/new_items_loading_widget.dart'; import 'package:paperless_mobile/features/documents/view/widgets/search/document_filter_panel.dart'; import 'package:paperless_mobile/features/documents/view/widgets/selection/bulk_delete_confirmation_dialog.dart'; -import 'package:paperless_mobile/features/documents/view/widgets/sort_documents_button.dart'; -import 'package:paperless_mobile/features/home/view/widget/app_drawer.dart'; +import 'package:paperless_mobile/features/documents/view/widgets/view_actions.dart'; import 'package:paperless_mobile/features/labels/bloc/providers/labels_bloc_provider.dart'; -import 'package:paperless_mobile/features/login/bloc/authentication_cubit.dart'; import 'package:paperless_mobile/features/saved_view/cubit/saved_view_cubit.dart'; -import 'package:paperless_mobile/features/saved_view/view/saved_view_selection_widget.dart'; +import 'package:paperless_mobile/features/saved_view/view/add_saved_view_page.dart'; +import 'package:paperless_mobile/features/saved_view/view/saved_view_list.dart'; import 'package:paperless_mobile/features/search/view/document_search_page.dart'; +import 'package:paperless_mobile/features/search_app_bar/view/search_app_bar.dart'; import 'package:paperless_mobile/features/settings/bloc/application_settings_cubit.dart'; import 'package:paperless_mobile/features/settings/bloc/application_settings_state.dart'; -import 'package:paperless_mobile/features/settings/model/view_type.dart'; import 'package:paperless_mobile/features/tasks/cubit/task_status_cubit.dart'; import 'package:paperless_mobile/generated/l10n.dart'; -import 'package:paperless_mobile/helpers/format_helpers.dart'; import 'package:paperless_mobile/helpers/message_helpers.dart'; class DocumentFilterIntent { @@ -46,6 +39,7 @@ class DocumentFilterIntent { }); } +//TODO: Refactor this class DocumentsPage extends StatefulWidget { const DocumentsPage({Key? key}) : super(key: key); @@ -53,56 +47,38 @@ class DocumentsPage extends StatefulWidget { State createState() => _DocumentsPageState(); } -class _DocumentsPageState extends State { - final ScrollController _scrollController = ScrollController(); - double _offset = 0; - double _last = 0; +class _DocumentsPageState extends State + with SingleTickerProviderStateMixin { + late final TabController _tabController; - static const double _savedViewWidgetHeight = 80 + 16; + int _currentTab = 0; @override void initState() { super.initState(); + _tabController = TabController( + length: 2, + vsync: this, + initialIndex: 0, + ); try { context.read().reload(); context.read().reload(); } on PaperlessServerException catch (error, stackTrace) { showErrorMessage(context, error, stackTrace); } - _scrollController - ..addListener(_listenForScrollChanges) - ..addListener(_listenForLoadNewData); + _tabController.addListener(_listenForTabChanges); } - void _listenForLoadNewData() async { - final currState = context.read().state; - if (_scrollController.offset >= - _scrollController.position.maxScrollExtent * 0.75 && - !currState.isLoading && - !currState.isLastPageLoaded) { - try { - await context.read().loadMore(); - } on PaperlessServerException catch (error, stackTrace) { - showErrorMessage(context, error, stackTrace); - } - } - } - - void _listenForScrollChanges() { - final current = _scrollController.offset; - _offset += _last - current; - - if (_offset <= -_savedViewWidgetHeight) _offset = -_savedViewWidgetHeight; - if (_offset >= 0) _offset = 0; - _last = current; - if (_offset <= 0 && _offset >= -_savedViewWidgetHeight) { - setState(() {}); - } + void _listenForTabChanges() { + setState(() { + _currentTab = _tabController.index; + }); } @override void dispose() { - _scrollController.dispose(); + _tabController.dispose(); super.dispose(); } @@ -140,149 +116,204 @@ class _DocumentsPageState extends State { }, builder: (context, connectivityState) { return Scaffold( - // appBar: PreferredSize( - // preferredSize: const Size.fromHeight( - // kToolbarHeight, - // ), - // child: BlocBuilder( - // builder: (context, state) { - // if (state.selection.isEmpty) { - // return DocumentSearchBar(); - // // return AppBar( - // // title: Text(S.of(context).documentsPageTitle + - // // " (${formatMaxCount(state.documents.length)})"), - // // actions: [ - // // IconButton( - // // icon: const Icon(Icons.search), - // // onPressed: () { - // // showMaterial3Search( - // // context: context, - // // delegate: DocumentSearchDelegate( - // // DocumentSearchCubit(context.read()), - // // searchFieldStyle: - // // Theme.of(context).textTheme.bodyLarge, - // // hintText: "Search documents", //TODO: INTL - // // ), - // // ); - // // }, - // // ), - // // const SortDocumentsButton(), - // // const AppOptionsPopupMenu( - // // displayedActions: [ - // // AppPopupMenuEntries.documentsSelectListView, - // // AppPopupMenuEntries.documentsSelectGridView, - // // AppPopupMenuEntries.divider, - // // AppPopupMenuEntries.openAboutThisAppDialog, - // // AppPopupMenuEntries.reportBug, - // // AppPopupMenuEntries.openSettings, - // // ], - // // ), - // // ], - // // ); - // } else { - // return AppBar( - // leading: IconButton( - // icon: const Icon(Icons.close), - // onPressed: () => - // context.read().resetSelection(), - // ), - // title: Text( - // '${state.selection.length} ${S.of(context).documentsSelectedText}'), - // actions: [ - // IconButton( - // icon: const Icon(Icons.delete), - // onPressed: () => _onDelete(context, state), - // ), - // ], - // ); - // } - // }, - // ), - // ), - // floatingActionButton: BlocBuilder( - // builder: (context, state) { - // final appliedFiltersCount = state.filter.appliedFiltersCount; - // return b.Badge( - // position: b.BadgePosition.topEnd(top: -12, end: -6), - // showBadge: appliedFiltersCount > 0, - // badgeContent: Text( - // '$appliedFiltersCount', - // style: const TextStyle( - // color: Colors.white, - // ), - // ), - // animationType: b.BadgeAnimationType.fade, - // badgeColor: Colors.red, - // child: FloatingActionButton( - // child: const Icon(Icons.filter_alt_outlined), - // onPressed: _openDocumentFilter, - // ), - // ); - // }, - // ), + floatingActionButton: BlocBuilder( + builder: (context, state) { + final appliedFiltersCount = state.filter.appliedFiltersCount; + return b.Badge( + position: b.BadgePosition.topEnd(top: -12, end: -6), + showBadge: appliedFiltersCount > 0, + badgeContent: Text( + '$appliedFiltersCount', + style: const TextStyle( + color: Colors.white, + ), + ), + animationType: b.BadgeAnimationType.fade, + badgeColor: Colors.red, + child: _currentTab == 0 + ? FloatingActionButton( + child: const Icon(Icons.filter_alt_outlined), + onPressed: _openDocumentFilter, + ) + : FloatingActionButton( + child: const Icon(Icons.add), + onPressed: () => _onCreateSavedView(state.filter), + ), + ); + }, + ), resizeToAvoidBottomInset: true, - body: NestedScrollView( - headerSliverBuilder: (context, innerBoxIsScrolled) { - return [ - SliverAppBar( - floating: true, - pinned: true, - snap: true, - title: SearchBar( - height: kToolbarHeight - 2, - supportingText: "Search documents", - onTap: () { - showDocumentSearchPage(context); - }, - leadingIcon: Icon(Icons.menu), - trailingIcon: CircleAvatar( - child: Text("A"), + body: WillPopScope( + onWillPop: () async { + if (context.read().state.selection.isNotEmpty) { + context.read().resetSelection(); + } + return false; + }, + child: NestedScrollView( + floatHeaderSlivers: true, + headerSliverBuilder: (context, innerBoxIsScrolled) => [ + SliverOverlapAbsorber( + // This widget takes the overlapping behavior of the SliverAppBar, + // and redirects it to the SliverOverlapInjector below. If it is + // missing, then it is possible for the nested "inner" scroll view + // below to end up under the SliverAppBar even when the inner + // scroll view thinks it has not been scrolled. + // This is not necessary if the "headerSliverBuilder" only builds + // widgets that do not overlap the next sliver. + handle: NestedScrollView.sliverOverlapAbsorberHandleFor( + context, + ), + sliver: SearchAppBar( + onOpenSearch: showDocumentSearchPage, + bottom: TabBar( + controller: _tabController, + isScrollable: true, + tabs: [ + Tab(text: S.of(context).documentsPageTitle), + Tab(text: S.of(context).savedViewsLabel), + ], ), ), - ) - ]; - }, - body: WillPopScope( - onWillPop: () async { - if (context - .read() - .state - .selection - .isNotEmpty) { - context.read().resetSelection(); - } - return false; - }, - child: RefreshIndicator( - onRefresh: _onRefresh, - notificationPredicate: (_) => connectivityState.isConnected, - child: BlocBuilder( - builder: (context, taskState) { - return _buildBody(connectivityState); - // return Stack( - // children: [ - // Positioned( - // left: 0, - // right: 0, - // top: _offset, - // child: BlocBuilder( - // builder: (context, state) { - // return ColoredBox( - // color: - // Theme.of(context).colorScheme.background, - // child: SavedViewSelectionWidget( - // height: _savedViewWidgetHeight, - // currentFilter: state.filter, - // enabled: state.selection.isEmpty && - // connectivityState.isConnected, - // ), - // ); - // }, - // ), - // ), - // ], - // ); + ), + ], + body: NotificationListener( + onNotification: (notification) { + final metrics = notification.metrics; + final desiredTab = + (metrics.pixels / metrics.maxScrollExtent).round(); + if (metrics.axis == Axis.horizontal && + _currentTab != desiredTab) { + setState(() => _currentTab = desiredTab); + } + return true; + }, + child: NotificationListener( + onNotification: (notification) { + // Listen for scroll notifications to load new data. + // Scroll controller does not work here due to nestedscrollview limitations. + final currState = context.read().state; + final max = notification.metrics.maxScrollExtent; + if (max == 0 || + _currentTab != 0 || + currState.isLoading || + currState.isLastPageLoaded) { + return true; + } + final offset = notification.metrics.pixels; + if (offset >= max * 0.7) { + context + .read() + .loadMore() + .onError( + (error, stackTrace) => showErrorMessage( + context, + error, + stackTrace, + ), + ); + } + return true; }, + child: TabBarView( + controller: _tabController, + children: [ + Builder( + builder: (context) { + return RefreshIndicator( + edgeOffset: kToolbarHeight, + onRefresh: _onReloadDocuments, + notificationPredicate: (_) => + connectivityState.isConnected, + child: CustomScrollView( + key: const PageStorageKey("documents"), + slivers: [ + SliverOverlapInjector( + handle: NestedScrollView + .sliverOverlapAbsorberHandleFor( + context), + ), + BlocBuilder( + buildWhen: (previous, current) => + !const ListEquality().equals( + previous.documents, + current.documents, + ) || + previous.selectedIds != + current.selectedIds, + builder: (context, state) { + if (state.hasLoaded && + state.documents.isEmpty) { + return SliverToBoxAdapter( + child: DocumentsEmptyState( + state: state, + onReset: () { + context + .read() + .resetFilter(); + }, + ), + ); + } + return BlocBuilder< + ApplicationSettingsCubit, + ApplicationSettingsState>( + builder: (context, settings) { + return SliverAdaptiveDocumentsView( + viewType: + settings.preferredViewType, + onTap: _openDetails, + onSelected: context + .read() + .toggleDocumentSelection, + hasInternetConnection: + connectivityState.isConnected, + onTagSelected: _addTagToFilter, + onCorrespondentSelected: + _addCorrespondentToFilter, + onDocumentTypeSelected: + _addDocumentTypeToFilter, + onStoragePathSelected: + _addStoragePathToFilter, + documents: state.documents, + hasLoaded: state.hasLoaded, + isLabelClickable: true, + isLoading: state.isLoading, + selectedDocumentIds: + state.selectedIds, + ); + }, + ); + }, + ), + ], + ), + ); + }, + ), + Builder( + builder: (context) { + return RefreshIndicator( + edgeOffset: kToolbarHeight, + onRefresh: _onReloadSavedViews, + notificationPredicate: (_) => + connectivityState.isConnected, + child: CustomScrollView( + key: const PageStorageKey("savedViews"), + slivers: [ + SliverOverlapInjector( + handle: NestedScrollView + .sliverOverlapAbsorberHandleFor( + context), + ), + const SavedViewList(), + ], + ), + ); + }, + ), + ], + ), ), ), ), @@ -293,28 +324,7 @@ class _DocumentsPageState extends State { ); } - BlocBuilder - _buildViewTypeButton() { - return BlocBuilder( - builder: (context, settingsState) => IconButton( - icon: Icon( - settingsState.preferredViewType == ViewType.grid - ? Icons.list - : Icons.grid_view_rounded, - ), - onPressed: () { - // Reset saved view widget position as scroll offset will be reset anyway. - setState(() { - _offset = 0; - _last = 0; - }); - final cubit = context.read(); - cubit.setViewType(cubit.state.preferredViewType.toggle()); - }, - ), - ); - } - + //TODO: Add app bar... void _onDelete(BuildContext context, DocumentsState documentsState) async { final shouldDelete = await showDialog( context: context, @@ -338,6 +348,25 @@ class _DocumentsPageState extends State { } } + void _onCreateSavedView(DocumentFilter filter) async { + final newView = await Navigator.of(context).push( + MaterialPageRoute( + builder: (context) => LabelsBlocProvider( + child: AddSavedViewPage( + currentFilter: filter, + ), + ), + ), + ); + if (newView != null) { + try { + await context.read().add(newView); + } on PaperlessServerException catch (error, stackTrace) { + showErrorMessage(context, error, stackTrace); + } + } + } + void _openDocumentFilter() async { final draggableSheetController = DraggableScrollableController(); final filterIntent = await showModalBottomSheet( @@ -373,12 +402,7 @@ class _DocumentsPageState extends State { try { if (filterIntent.shouldReset) { await context.read().resetFilter(); - context.read().unselectView(); } else { - if (filterIntent.filter != - context.read().state.filter) { - context.read().unselectView(); - } await context .read() .updateFilter(filter: filterIntent.filter!); @@ -389,75 +413,11 @@ class _DocumentsPageState extends State { } } - String _formatDocumentCount(int count) { - return count > 99 ? "99+" : count.toString(); - } - - Widget _buildBody(ConnectivityState connectivityState) { - final isConnected = connectivityState == ConnectivityState.connected; - return BlocBuilder( - builder: (context, settings) { - return BlocBuilder( - buildWhen: (previous, current) => - !const ListEquality() - .equals(previous.documents, current.documents) || - previous.selectedIds != current.selectedIds, - builder: (context, state) { - if (state.hasLoaded && state.documents.isEmpty) { - return DocumentsEmptyState( - state: state, - onReset: () { - context.read().resetFilter(); - context.read().unselectView(); - }, - ); - } - - return AdaptiveDocumentsView( - viewType: settings.preferredViewType, - state: state, - scrollController: _scrollController, - onTap: _openDetails, - onSelected: _onSelected, - hasInternetConnection: isConnected, - onTagSelected: _addTagToFilter, - onCorrespondentSelected: _addCorrespondentToFilter, - onDocumentTypeSelected: _addDocumentTypeToFilter, - onStoragePathSelected: _addStoragePathToFilter, - pageLoadingWidget: const NewItemsLoadingWidget(), - beforeItems: SizedBox( - height: kToolbarHeight, - child: Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, - children: [ - const SortDocumentsButton(), - IconButton( - icon: Icon( - settings.preferredViewType == ViewType.grid - ? Icons.list - : Icons.grid_view_rounded, - ), - onPressed: () => - context.read().setViewType( - settings.preferredViewType.toggle(), - ), - ), - ], - ), - ), - ); - }, - ); - }, - ); - } - Future _openDetails(DocumentModel document) async { - final potentiallyUpdatedModel = - await Navigator.of(context).push( + final updatedModel = await Navigator.of(context).push( _buildDetailsPageRoute(document), ); - if (potentiallyUpdatedModel != document) { + if (updatedModel != document) { context.read().reload(); } } @@ -558,15 +518,19 @@ class _DocumentsPageState extends State { } } - void _onSelected(DocumentModel model) { - context.read().toggleDocumentSelection(model); - } - - Future _onRefresh() async { + Future _onReloadDocuments() async { try { // We do not await here on purpose so we can show a linear progress indicator below the app bar. - context.read().reload(); - context.read().reload(); + await context.read().reload(); + } on PaperlessServerException catch (error, stackTrace) { + showErrorMessage(context, error, stackTrace); + } + } + + Future _onReloadSavedViews() async { + try { + // We do not await here on purpose so we can show a linear progress indicator below the app bar. + await context.read().reload(); } on PaperlessServerException catch (error, stackTrace) { showErrorMessage(context, error, stackTrace); } diff --git a/lib/features/documents/view/widgets/adaptive_documents_view.dart b/lib/features/documents/view/widgets/adaptive_documents_view.dart new file mode 100644 index 0000000..ee1d343 --- /dev/null +++ b/lib/features/documents/view/widgets/adaptive_documents_view.dart @@ -0,0 +1,232 @@ +import 'package:flutter/material.dart'; +import 'package:paperless_api/paperless_api.dart'; +import 'package:paperless_mobile/core/repository/provider/label_repositories_provider.dart'; +import 'package:paperless_mobile/features/documents/view/widgets/documents_list_loading_widget.dart'; +import 'package:paperless_mobile/features/documents/bloc/documents_state.dart'; +import 'package:paperless_mobile/features/documents/view/widgets/items/document_grid_item.dart'; +import 'package:paperless_mobile/features/documents/view/widgets/items/document_list_item.dart'; +import 'package:paperless_mobile/features/settings/model/view_type.dart'; + +abstract class AdaptiveDocumentsView extends StatelessWidget { + final List documents; + final bool isLoading; + final bool hasLoaded; + final bool enableHeroAnimation; + final List selectedDocumentIds; + final ViewType viewType; + final void Function(DocumentModel)? onTap; + final void Function(DocumentModel)? onSelected; + final bool hasInternetConnection; + final bool isLabelClickable; + final void Function(int id)? onTagSelected; + final void Function(int? id)? onCorrespondentSelected; + final void Function(int? id)? onDocumentTypeSelected; + final void Function(int? id)? onStoragePathSelected; + + const AdaptiveDocumentsView({ + super.key, + this.selectedDocumentIds = const [], + required this.documents, + this.onTap, + this.onSelected, + this.viewType = ViewType.list, + required this.hasInternetConnection, + required this.isLabelClickable, + this.onTagSelected, + this.onCorrespondentSelected, + this.onDocumentTypeSelected, + this.onStoragePathSelected, + required this.isLoading, + required this.hasLoaded, + this.enableHeroAnimation = true, + }); +} + +class SliverAdaptiveDocumentsView extends AdaptiveDocumentsView { + const SliverAdaptiveDocumentsView({ + super.key, + required super.documents, + required super.hasInternetConnection, + required super.isLabelClickable, + super.onCorrespondentSelected, + super.onDocumentTypeSelected, + super.onStoragePathSelected, + super.onSelected, + super.onTagSelected, + super.onTap, + super.selectedDocumentIds, + super.viewType, + required super.isLoading, + required super.hasLoaded, + }); + + @override + Widget build(BuildContext context) { + switch (viewType) { + case ViewType.grid: + return _buildGridView(); + case ViewType.list: + return _buildListView(); + } + } + + Widget _buildListView() { + if (!hasLoaded && isLoading) { + return const DocumentsListLoadingWidget(); + } + return SliverList( + delegate: SliverChildBuilderDelegate( + childCount: documents.length, + (context, index) { + final document = documents.elementAt(index); + return LabelRepositoriesProvider( + child: DocumentListItem( + isLabelClickable: isLabelClickable, + document: document, + onTap: onTap, + isSelected: selectedDocumentIds.contains(document.id), + onSelected: onSelected, + isSelectionActive: selectedDocumentIds.isNotEmpty, + onTagSelected: onTagSelected, + onCorrespondentSelected: onCorrespondentSelected, + onDocumentTypeSelected: onDocumentTypeSelected, + onStoragePathSelected: onStoragePathSelected, + ), + ); + }, + ), + ); + } + + Widget _buildGridView() { + if (!hasLoaded && isLoading) { + return const DocumentsListLoadingWidget(); + } + return SliverGrid.builder( + gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount( + crossAxisCount: 2, + mainAxisSpacing: 4, + crossAxisSpacing: 4, + childAspectRatio: 1 / 2, + ), + itemCount: documents.length, + itemBuilder: (context, index) { + final document = documents.elementAt(index); + return DocumentGridItem( + document: document, + onTap: onTap, + isSelected: selectedDocumentIds.contains(document.id), + onSelected: onSelected, + isSelectionActive: selectedDocumentIds.isNotEmpty, + isLabelClickable: isLabelClickable, + onTagSelected: onTagSelected, + onCorrespondentSelected: onCorrespondentSelected, + onDocumentTypeSelected: onDocumentTypeSelected, + onStoragePathSelected: onStoragePathSelected, + enableHeroAnimation: enableHeroAnimation, + ); + }, + ); + } +} + +class DefaultAdaptiveDocumentsView extends AdaptiveDocumentsView { + final ScrollController? scrollController; + const DefaultAdaptiveDocumentsView({ + super.key, + required super.documents, + required super.hasInternetConnection, + required super.isLabelClickable, + required super.isLoading, + required super.hasLoaded, + super.onCorrespondentSelected, + super.onDocumentTypeSelected, + super.onStoragePathSelected, + super.onSelected, + super.onTagSelected, + super.onTap, + this.scrollController, + super.selectedDocumentIds, + super.viewType, + super.enableHeroAnimation = true, + }); + + @override + Widget build(BuildContext context) { + switch (viewType) { + case ViewType.grid: + return _buildGridView(); + case ViewType.list: + return _buildListView(); + } + } + + Widget _buildListView() { + if (!hasLoaded && isLoading) { + return const CustomScrollView(slivers: [ + DocumentsListLoadingWidget(), + ]); + } + + return ListView.builder( + controller: scrollController, + primary: false, + itemCount: documents.length, + itemBuilder: (context, index) { + final document = documents.elementAt(index); + return LabelRepositoriesProvider( + child: DocumentListItem( + isLabelClickable: isLabelClickable, + document: document, + onTap: onTap, + isSelected: selectedDocumentIds.contains(document.id), + onSelected: onSelected, + isSelectionActive: selectedDocumentIds.isNotEmpty, + onTagSelected: onTagSelected, + onCorrespondentSelected: onCorrespondentSelected, + onDocumentTypeSelected: onDocumentTypeSelected, + onStoragePathSelected: onStoragePathSelected, + enableHeroAnimation: enableHeroAnimation, + ), + ); + }, + ); + } + + Widget _buildGridView() { + if (!hasLoaded && isLoading) { + return const CustomScrollView( + slivers: [ + DocumentsListLoadingWidget(), + ], + ); //TODO: Build grid skeleton + } + return GridView.builder( + controller: scrollController, + primary: false, + gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount( + crossAxisCount: 2, + mainAxisSpacing: 4, + crossAxisSpacing: 4, + childAspectRatio: 1 / 2, + ), + itemCount: documents.length, + itemBuilder: (context, index) { + final document = documents.elementAt(index); + return DocumentGridItem( + document: document, + onTap: onTap, + isSelected: selectedDocumentIds.contains(document.id), + onSelected: onSelected, + isSelectionActive: selectedDocumentIds.isNotEmpty, + isLabelClickable: isLabelClickable, + onTagSelected: onTagSelected, + onCorrespondentSelected: onCorrespondentSelected, + onDocumentTypeSelected: onDocumentTypeSelected, + onStoragePathSelected: onStoragePathSelected, + enableHeroAnimation: enableHeroAnimation, + ); + }, + ); + } +} diff --git a/lib/features/documents/view/widgets/document_grid_loading_widget.dart b/lib/features/documents/view/widgets/document_grid_loading_widget.dart new file mode 100644 index 0000000..e69de29 diff --git a/lib/features/documents/view/widgets/documents_empty_state.dart b/lib/features/documents/view/widgets/documents_empty_state.dart index 9612fc7..4c3d25b 100644 --- a/lib/features/documents/view/widgets/documents_empty_state.dart +++ b/lib/features/documents/view/widgets/documents_empty_state.dart @@ -7,11 +7,11 @@ import 'package:paperless_mobile/generated/l10n.dart'; class DocumentsEmptyState extends StatelessWidget { final PagedDocumentsState state; - final VoidCallback onReset; + final VoidCallback? onReset; const DocumentsEmptyState({ Key? key, required this.state, - required this.onReset, + this.onReset, }) : super(key: key); @override @@ -20,7 +20,7 @@ class DocumentsEmptyState extends StatelessWidget { child: EmptyState( title: S.of(context).documentsPageEmptyStateOopsText, subtitle: S.of(context).documentsPageEmptyStateNothingHereText, - bottomChild: state.filter != DocumentFilter.initial + bottomChild: state.filter != DocumentFilter.initial && onReset != null ? TextButton( onPressed: onReset, child: Text( diff --git a/lib/core/widgets/documents_list_loading_widget.dart b/lib/features/documents/view/widgets/documents_list_loading_widget.dart similarity index 80% rename from lib/core/widgets/documents_list_loading_widget.dart rename to lib/features/documents/view/widgets/documents_list_loading_widget.dart index 8d1575c..433a607 100644 --- a/lib/core/widgets/documents_list_loading_widget.dart +++ b/lib/features/documents/view/widgets/documents_list_loading_widget.dart @@ -5,37 +5,23 @@ import 'package:paperless_mobile/extensions/flutter_extensions.dart'; import 'package:shimmer/shimmer.dart'; class DocumentsListLoadingWidget extends StatelessWidget { - final List beforeWidgets; - final List afterWidgets; - static const _tags = [" ", " ", " "]; static const _titleLengths = [double.infinity, 150.0, 200.0]; static const _correspondentLengths = [200.0, 300.0, 150.0]; static const _fontSize = 16.0; - const DocumentsListLoadingWidget({ - super.key, - this.beforeWidgets = const [], - this.afterWidgets = const [], + const DocumentsListLoadingWidget({super.key }); @override Widget build(BuildContext context) { final _random = Random(); - return CustomScrollView( - slivers: [ - SliverList( - delegate: SliverChildListDelegate(beforeWidgets), - ), - SliverList( - delegate: SliverChildBuilderDelegate( - (context, index) { - return _buildFakeListItem(context, _random); - }, - ), - ), - SliverList(delegate: SliverChildListDelegate(afterWidgets)) - ], + return SliverList( + delegate: SliverChildBuilderDelegate( + (context, index) { + return _buildFakeListItem(context, _random); + }, + ), ); } diff --git a/lib/features/documents/view/widgets/grid/document_grid_item.dart b/lib/features/documents/view/widgets/items/document_grid_item.dart similarity index 78% rename from lib/features/documents/view/widgets/grid/document_grid_item.dart rename to lib/features/documents/view/widgets/items/document_grid_item.dart index 00a50be..4c494e8 100644 --- a/lib/features/documents/view/widgets/grid/document_grid_item.dart +++ b/lib/features/documents/view/widgets/items/document_grid_item.dart @@ -1,38 +1,35 @@ import 'package:flutter/material.dart'; import 'package:paperless_api/paperless_api.dart'; import 'package:paperless_mobile/features/documents/view/widgets/document_preview.dart'; +import 'package:paperless_mobile/features/documents/view/widgets/items/document_item.dart'; import 'package:paperless_mobile/features/labels/correspondent/view/widgets/correspondent_widget.dart'; import 'package:paperless_mobile/features/labels/document_type/view/widgets/document_type_widget.dart'; import 'package:paperless_mobile/features/labels/tags/view/widgets/tags_widget.dart'; import 'package:intl/intl.dart'; -class DocumentGridItem extends StatelessWidget { - final DocumentModel document; - final bool isSelected; - final void Function(DocumentModel) onTap; - final void Function(DocumentModel) onSelected; - final bool isAtLeastOneSelected; - final bool Function(int tagId) isTagSelectedPredicate; - final void Function(int tagId)? onTagSelected; - +class DocumentGridItem extends DocumentItem { const DocumentGridItem({ - Key? key, - required this.document, - required this.onTap, - required this.onSelected, - required this.isSelected, - required this.isAtLeastOneSelected, - required this.isTagSelectedPredicate, - required this.onTagSelected, - }) : super(key: key); + super.key, + required super.document, + required super.isSelected, + required super.isSelectionActive, + required super.isLabelClickable, + super.onCorrespondentSelected, + super.onDocumentTypeSelected, + super.onSelected, + super.onStoragePathSelected, + super.onTagSelected, + super.onTap, + required super.enableHeroAnimation, + }); @override Widget build(BuildContext context) { return GestureDetector( onTap: _onTap, - onLongPress: () => onSelected(document), + onLongPress: onSelected != null ? () => onSelected!(document) : null, child: AbsorbPointer( - absorbing: isAtLeastOneSelected, + absorbing: isSelectionActive, child: Padding( padding: const EdgeInsets.all(8.0), child: Card( @@ -48,6 +45,7 @@ class DocumentGridItem extends StatelessWidget { child: DocumentPreview( id: document.id, borderRadius: 12.0, + enableHero: enableHeroAnimation, ), ), Expanded( @@ -94,10 +92,10 @@ class DocumentGridItem extends StatelessWidget { } void _onTap() { - if (isAtLeastOneSelected || isSelected) { - onSelected(document); + if (isSelectionActive || isSelected) { + onSelected?.call(document); } else { - onTap(document); + onTap?.call(document); } } } diff --git a/lib/features/documents/view/widgets/items/document_item.dart b/lib/features/documents/view/widgets/items/document_item.dart new file mode 100644 index 0000000..a19fef3 --- /dev/null +++ b/lib/features/documents/view/widgets/items/document_item.dart @@ -0,0 +1,32 @@ +import 'package:flutter/material.dart'; +import 'package:paperless_api/paperless_api.dart'; + +abstract class DocumentItem extends StatelessWidget { + final DocumentModel document; + final void Function(DocumentModel)? onTap; + final void Function(DocumentModel)? onSelected; + final bool isSelected; + final bool isSelectionActive; + final bool isLabelClickable; + final bool enableHeroAnimation; + + final void Function(int tagId)? onTagSelected; + final void Function(int? correspondentId)? onCorrespondentSelected; + final void Function(int? documentTypeId)? onDocumentTypeSelected; + final void Function(int? id)? onStoragePathSelected; + + const DocumentItem({ + super.key, + required this.document, + this.onTap, + this.onSelected, + required this.isSelected, + required this.isSelectionActive, + required this.isLabelClickable, + this.onTagSelected, + this.onCorrespondentSelected, + this.onDocumentTypeSelected, + this.onStoragePathSelected, + required this.enableHeroAnimation, + }); +} diff --git a/lib/features/documents/view/widgets/list/document_list_item.dart b/lib/features/documents/view/widgets/items/document_list_item.dart similarity index 66% rename from lib/features/documents/view/widgets/list/document_list_item.dart rename to lib/features/documents/view/widgets/items/document_list_item.dart index 116bf63..f63f165 100644 --- a/lib/features/documents/view/widgets/list/document_list_item.dart +++ b/lib/features/documents/view/widgets/items/document_list_item.dart @@ -1,39 +1,26 @@ import 'package:flutter/material.dart'; -import 'package:paperless_api/paperless_api.dart'; import 'package:paperless_mobile/features/documents/view/widgets/document_preview.dart'; +import 'package:paperless_mobile/features/documents/view/widgets/items/document_item.dart'; import 'package:paperless_mobile/features/labels/correspondent/view/widgets/correspondent_widget.dart'; import 'package:paperless_mobile/features/labels/tags/view/widgets/tags_widget.dart'; -class DocumentListItem extends StatelessWidget { +class DocumentListItem extends DocumentItem { static const _a4AspectRatio = 1 / 1.4142; - final DocumentModel document; - final void Function(DocumentModel)? onTap; - final void Function(DocumentModel)? onSelected; - final bool isSelected; - final bool isAtLeastOneSelected; - final bool isLabelClickable; - - final void Function(int tagId)? onTagSelected; - final void Function(int? correspondentId)? onCorrespondentSelected; - final void Function(int? documentTypeId)? onDocumentTypeSelected; - final void Function(int? id)? onStoragePathSelected; - - final bool enableHeroAnimation; const DocumentListItem({ - Key? key, - required this.document, - this.onTap, - this.onSelected, - this.isSelected = false, - this.isAtLeastOneSelected = false, - this.isLabelClickable = true, - this.onTagSelected, - this.onCorrespondentSelected, - this.onDocumentTypeSelected, - this.onStoragePathSelected, - this.enableHeroAnimation = true, - }) : super(key: key); + super.key, + required super.document, + required super.isSelected, + required super.isSelectionActive, + required super.isLabelClickable, + super.onCorrespondentSelected, + super.onDocumentTypeSelected, + super.onSelected, + super.onStoragePathSelected, + super.onTagSelected, + super.onTap, + super.enableHeroAnimation = true, + }); @override Widget build(BuildContext context) { @@ -50,7 +37,7 @@ class DocumentListItem extends StatelessWidget { Row( children: [ AbsorbPointer( - absorbing: isAtLeastOneSelected, + absorbing: isSelectionActive, child: CorrespondentWidget( isClickable: isLabelClickable, correspondentId: document.correspondent, @@ -69,7 +56,7 @@ class DocumentListItem extends StatelessWidget { subtitle: Padding( padding: const EdgeInsets.symmetric(vertical: 4), child: AbsorbPointer( - absorbing: isAtLeastOneSelected, + absorbing: isSelectionActive, child: TagsWidget( isClickable: isLabelClickable, tagIds: document.tags, @@ -95,7 +82,7 @@ class DocumentListItem extends StatelessWidget { } void _onTap() { - if (isAtLeastOneSelected || isSelected) { + if (isSelectionActive || isSelected) { onSelected?.call(document); } else { onTap?.call(document); diff --git a/lib/features/documents/view/widgets/list/adaptive_documents_view.dart b/lib/features/documents/view/widgets/list/adaptive_documents_view.dart deleted file mode 100644 index 4e750db..0000000 --- a/lib/features/documents/view/widgets/list/adaptive_documents_view.dart +++ /dev/null @@ -1,114 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:paperless_api/paperless_api.dart'; -import 'package:paperless_mobile/core/repository/provider/label_repositories_provider.dart'; -import 'package:paperless_mobile/features/documents/bloc/documents_state.dart'; -import 'package:paperless_mobile/features/documents/view/widgets/grid/document_grid_item.dart'; -import 'package:paperless_mobile/features/documents/view/widgets/list/document_list_item.dart'; -import 'package:paperless_mobile/features/settings/model/view_type.dart'; - -class AdaptiveDocumentsView extends StatelessWidget { - final DocumentsState state; - final ViewType viewType; - final Widget? beforeItems; - final void Function(DocumentModel) onTap; - final void Function(DocumentModel) onSelected; - final ScrollController scrollController; - final bool hasInternetConnection; - final bool isLabelClickable; - final void Function(int id)? onTagSelected; - final void Function(int? id)? onCorrespondentSelected; - final void Function(int? id)? onDocumentTypeSelected; - final void Function(int? id)? onStoragePathSelected; - final Widget pageLoadingWidget; - - const AdaptiveDocumentsView({ - super.key, - required this.onTap, - required this.scrollController, - required this.state, - required this.onSelected, - required this.hasInternetConnection, - this.isLabelClickable = true, - this.onTagSelected, - this.onCorrespondentSelected, - this.onDocumentTypeSelected, - this.onStoragePathSelected, - required this.pageLoadingWidget, - this.beforeItems, - required this.viewType, - }); - - @override - Widget build(BuildContext context) { - return CustomScrollView( - controller: scrollController, - physics: const AlwaysScrollableScrollPhysics(), - slivers: [ - SliverToBoxAdapter(child: beforeItems), - if (viewType == ViewType.list) _buildListView() else _buildGridView(), - if (state.hasLoaded && state.isLoading) - SliverToBoxAdapter(child: pageLoadingWidget), - ], - ); - } - - SliverList _buildListView() { - return SliverList( - delegate: SliverChildBuilderDelegate( - childCount: state.documents.length, - (context, index) { - final document = state.documents.elementAt(index); - return LabelRepositoriesProvider( - child: DocumentListItem( - isLabelClickable: isLabelClickable, - document: document, - onTap: onTap, - isSelected: state.selectedIds.contains(document.id), - onSelected: onSelected, - isAtLeastOneSelected: state.selection.isNotEmpty, - onTagSelected: onTagSelected, - onCorrespondentSelected: onCorrespondentSelected, - onDocumentTypeSelected: onDocumentTypeSelected, - onStoragePathSelected: onStoragePathSelected, - ), - ); - }, - ), - ); - } - - Widget _buildGridView() { - return SliverGrid.builder( - gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount( - crossAxisCount: 2, - mainAxisSpacing: 4, - crossAxisSpacing: 4, - childAspectRatio: 1 / 2, - ), - itemCount: state.documents.length, - itemBuilder: (context, index) { - if (state.hasLoaded && - state.isLoading && - index == state.documents.length) { - return Center(child: pageLoadingWidget); - } - final document = state.documents.elementAt(index); - return DocumentGridItem( - document: document, - onTap: onTap, - isSelected: state.selectedIds.contains(document.id), - onSelected: onSelected, - isAtLeastOneSelected: state.selection.isNotEmpty, - isTagSelectedPredicate: (int tagId) { - return state.filter.tags is IdsTagsQuery - ? (state.filter.tags as IdsTagsQuery) - .includedIds - .contains(tagId) - : false; - }, - onTagSelected: onTagSelected, - ); - }, - ); - } -} diff --git a/lib/features/documents/view/widgets/search/document_filter_form.dart b/lib/features/documents/view/widgets/search/document_filter_form.dart new file mode 100644 index 0000000..f0157ac --- /dev/null +++ b/lib/features/documents/view/widgets/search/document_filter_form.dart @@ -0,0 +1,214 @@ +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/form_builder_fields/extended_date_range_form_field/form_builder_extended_date_range_picker.dart'; +import 'package:paperless_mobile/extensions/flutter_extensions.dart'; +import 'package:paperless_mobile/features/labels/bloc/label_cubit.dart'; +import 'package:paperless_mobile/features/labels/bloc/label_state.dart'; +import 'package:paperless_mobile/features/labels/tags/view/widgets/tags_form_field.dart'; +import 'package:paperless_mobile/features/labels/view/widgets/label_form_field.dart'; +import 'package:paperless_mobile/generated/l10n.dart'; + +import 'text_query_form_field.dart'; + +class DocumentFilterForm extends StatefulWidget { + static const fkCorrespondent = DocumentModel.correspondentKey; + static const fkDocumentType = DocumentModel.documentTypeKey; + static const fkStoragePath = DocumentModel.storagePathKey; + static const fkQuery = "query"; + static const fkCreatedAt = DocumentModel.createdKey; + static const fkAddedAt = DocumentModel.addedKey; + + static DocumentFilter assembleFilter( + GlobalKey formKey, DocumentFilter initialFilter) { + formKey.currentState?.save(); + final v = formKey.currentState!.value; + return DocumentFilter( + correspondent: + v[DocumentFilterForm.fkCorrespondent] as IdQueryParameter? ?? + DocumentFilter.initial.correspondent, + documentType: v[DocumentFilterForm.fkDocumentType] as IdQueryParameter? ?? + DocumentFilter.initial.documentType, + storagePath: v[DocumentFilterForm.fkStoragePath] as IdQueryParameter? ?? + DocumentFilter.initial.storagePath, + tags: + v[DocumentModel.tagsKey] as TagsQuery? ?? DocumentFilter.initial.tags, + query: v[DocumentFilterForm.fkQuery] as TextQuery? ?? + DocumentFilter.initial.query, + created: (v[DocumentFilterForm.fkCreatedAt] as DateRangeQuery), + added: (v[DocumentFilterForm.fkAddedAt] as DateRangeQuery), + asnQuery: initialFilter.asnQuery, + page: 1, + pageSize: initialFilter.pageSize, + sortField: initialFilter.sortField, + sortOrder: initialFilter.sortOrder, + ); + } + + final Widget? header; + final GlobalKey formKey; + final DocumentFilter initialFilter; + final ScrollController? scrollController; + final EdgeInsets padding; + const DocumentFilterForm({ + super.key, + this.header, + required this.formKey, + required this.initialFilter, + this.scrollController, + this.padding = const EdgeInsets.symmetric(vertical: 8, horizontal: 16), + }); + + @override + State createState() => _DocumentFilterFormState(); +} + +class _DocumentFilterFormState extends State { + late bool _allowOnlyExtendedQuery; + + @override + void initState() { + super.initState(); + _allowOnlyExtendedQuery = widget.initialFilter.forceExtendedQuery; + } + + @override + Widget build(BuildContext context) { + return FormBuilder( + key: widget.formKey, + child: CustomScrollView( + controller: widget.scrollController, + slivers: [ + if (widget.header != null) widget.header!, + ..._buildFormFieldList(), + SliverToBoxAdapter( + child: SizedBox( + height: 32, + ), + ), + ], + ), + ); + } + + List _buildFormFieldList() { + return [ + _buildQueryFormField(), + Align( + alignment: Alignment.centerLeft, + child: Text( + S.of(context).documentFilterAdvancedLabel, + style: Theme.of(context).textTheme.bodySmall, + ), + ), + FormBuilderExtendedDateRangePicker( + name: DocumentFilterForm.fkCreatedAt, + initialValue: widget.initialFilter.created, + labelText: S.of(context).documentCreatedPropertyLabel, + onChanged: (_) { + _checkQueryConstraints(); + }, + ), + FormBuilderExtendedDateRangePicker( + name: DocumentFilterForm.fkAddedAt, + initialValue: widget.initialFilter.added, + labelText: S.of(context).documentAddedPropertyLabel, + onChanged: (_) { + _checkQueryConstraints(); + }, + ), + _buildCorrespondentFormField(), + _buildDocumentTypeFormField(), + _buildStoragePathFormField(), + _buildTagsFormField(), + ] + .map((w) => SliverPadding( + padding: widget.padding, + sliver: SliverToBoxAdapter(child: w), + )) + .toList(); + } + + void _checkQueryConstraints() { + final filter = + DocumentFilterForm.assembleFilter(widget.formKey, widget.initialFilter); + if (filter.forceExtendedQuery) { + setState(() => _allowOnlyExtendedQuery = true); + final queryField = + widget.formKey.currentState?.fields[DocumentFilterForm.fkQuery]; + queryField?.didChange( + (queryField.value as TextQuery?) + ?.copyWith(queryType: QueryType.extended), + ); + } else { + setState(() => _allowOnlyExtendedQuery = false); + } + } + + Widget _buildDocumentTypeFormField() { + return BlocBuilder, LabelState>( + builder: (context, state) { + return LabelFormField( + formBuilderState: widget.formKey.currentState, + name: DocumentFilterForm.fkDocumentType, + labelOptions: state.labels, + textFieldLabel: S.of(context).documentDocumentTypePropertyLabel, + initialValue: widget.initialFilter.documentType, + prefixIcon: const Icon(Icons.description_outlined), + ); + }, + ); + } + + Widget _buildCorrespondentFormField() { + return BlocBuilder, LabelState>( + builder: (context, state) { + return LabelFormField( + formBuilderState: widget.formKey.currentState, + name: DocumentFilterForm.fkCorrespondent, + labelOptions: state.labels, + textFieldLabel: S.of(context).documentCorrespondentPropertyLabel, + initialValue: widget.initialFilter.correspondent, + prefixIcon: const Icon(Icons.person_outline), + ); + }, + ); + } + + Widget _buildStoragePathFormField() { + return BlocBuilder, LabelState>( + builder: (context, state) { + return LabelFormField( + formBuilderState: widget.formKey.currentState, + name: DocumentFilterForm.fkStoragePath, + labelOptions: state.labels, + textFieldLabel: S.of(context).documentStoragePathPropertyLabel, + initialValue: widget.initialFilter.storagePath, + prefixIcon: const Icon(Icons.folder_outlined), + ); + }, + ); + } + + Widget _buildQueryFormField() { + return TextQueryFormField( + name: DocumentFilterForm.fkQuery, + onlyExtendedQueryAllowed: _allowOnlyExtendedQuery, + initialValue: widget.initialFilter.query, + ); + } + + BlocBuilder, LabelState> _buildTagsFormField() { + return BlocBuilder, LabelState>( + builder: (context, state) { + return TagFormField( + name: DocumentModel.tagsKey, + initialValue: widget.initialFilter.tags, + allowCreation: false, + selectableOptions: state.labels, + ); + }, + ); + } +} diff --git a/lib/features/documents/view/widgets/search/document_filter_panel.dart b/lib/features/documents/view/widgets/search/document_filter_panel.dart index 028b65b..8cc5e5f 100644 --- a/lib/features/documents/view/widgets/search/document_filter_panel.dart +++ b/lib/features/documents/view/widgets/search/document_filter_panel.dart @@ -7,6 +7,7 @@ import 'package:paperless_api/paperless_api.dart'; import 'package:paperless_mobile/core/widgets/form_builder_fields/extended_date_range_form_field/form_builder_extended_date_range_picker.dart'; import 'package:paperless_mobile/extensions/flutter_extensions.dart'; import 'package:paperless_mobile/features/documents/view/pages/documents_page.dart'; +import 'package:paperless_mobile/features/documents/view/widgets/search/document_filter_form.dart'; import 'package:paperless_mobile/features/documents/view/widgets/search/text_query_form_field.dart'; import 'package:paperless_mobile/features/labels/bloc/label_cubit.dart'; import 'package:paperless_mobile/features/labels/bloc/label_state.dart'; @@ -32,22 +33,14 @@ class DocumentFilterPanel extends StatefulWidget { } class _DocumentFilterPanelState extends State { - static const fkCorrespondent = DocumentModel.correspondentKey; - static const fkDocumentType = DocumentModel.documentTypeKey; - static const fkStoragePath = DocumentModel.storagePathKey; - static const fkQuery = "query"; - static const fkCreatedAt = DocumentModel.createdKey; - static const fkAddedAt = DocumentModel.addedKey; - final _formKey = GlobalKey(); - late bool _allowOnlyExtendedQuery; double _heightAnimationValue = 0; @override void initState() { super.initState(); - _allowOnlyExtendedQuery = widget.initialFilter.forceExtendedQuery; + widget.draggableSheetController.addListener(animateTitleByDrag); } @@ -106,100 +99,59 @@ class _DocumentFilterPanelState extends State { ), ), resizeToAvoidBottomInset: true, - body: FormBuilder( - key: _formKey, - child: _buildFormList(context), + body: DocumentFilterForm( + formKey: _formKey, + scrollController: widget.scrollController, + initialFilter: widget.initialFilter, + header: _buildPanelHeader(), ), ), ); } - Widget _buildFormList(BuildContext context) { - return CustomScrollView( - controller: widget.scrollController, - slivers: [ - SliverAppBar( - pinned: true, - automaticallyImplyLeading: false, - toolbarHeight: kToolbarHeight + 22, - title: SizedBox( - width: MediaQuery.of(context).size.width, - child: Column( - mainAxisSize: MainAxisSize.max, - crossAxisAlignment: CrossAxisAlignment.center, - children: [ - Opacity( - opacity: 1 - _heightAnimationValue, - child: Padding( - padding: EdgeInsets.only(bottom: 11), - child: _buildDragHandle(), - ), - ), - Align( - alignment: Alignment.centerLeft, - child: Stack( - alignment: Alignment.centerLeft, - children: [ - Opacity( - opacity: max(0, (_heightAnimationValue - 0.5) * 2), - child: GestureDetector( - onTap: () => Navigator.of(context).pop(), - child: const Icon(Icons.expand_more_rounded), - ), - ), - Padding( - padding: - EdgeInsets.only(left: _heightAnimationValue * 48), - child: Text(S.of(context).documentFilterTitle), - ), - ], - ), - ), - ], + Widget _buildPanelHeader() { + return SliverAppBar( + pinned: true, + automaticallyImplyLeading: false, + toolbarHeight: kToolbarHeight + 22, + title: SizedBox( + width: MediaQuery.of(context).size.width, + child: Column( + mainAxisSize: MainAxisSize.max, + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + Opacity( + opacity: 1 - _heightAnimationValue, + child: Padding( + padding: const EdgeInsets.only(bottom: 11), + child: _buildDragHandle(), + ), ), - ), + Align( + alignment: Alignment.centerLeft, + child: Stack( + alignment: Alignment.centerLeft, + children: [ + Opacity( + opacity: max(0, (_heightAnimationValue - 0.5) * 2), + child: GestureDetector( + onTap: () => Navigator.of(context).pop(), + child: const Icon(Icons.expand_more_rounded), + ), + ), + Padding( + padding: EdgeInsets.only(left: _heightAnimationValue * 48), + child: Text(S.of(context).documentFilterTitle), + ), + ], + ), + ), + ], ), - ..._buildFormFieldList(), - ], + ), ); } - List _buildFormFieldList() { - return [ - _buildQueryFormField().paddedSymmetrically(vertical: 8, horizontal: 16), - Align( - alignment: Alignment.centerLeft, - child: Text( - S.of(context).documentFilterAdvancedLabel, - style: Theme.of(context).textTheme.bodySmall, - ), - ).paddedSymmetrically(vertical: 8, horizontal: 16), - FormBuilderExtendedDateRangePicker( - name: fkCreatedAt, - initialValue: widget.initialFilter.created, - labelText: S.of(context).documentCreatedPropertyLabel, - onChanged: (_) { - _checkQueryConstraints(); - }, - ).paddedSymmetrically(vertical: 8, horizontal: 16), - FormBuilderExtendedDateRangePicker( - name: fkAddedAt, - initialValue: widget.initialFilter.added, - labelText: S.of(context).documentAddedPropertyLabel, - onChanged: (_) { - _checkQueryConstraints(); - }, - ).paddedSymmetrically(vertical: 8, horizontal: 16), - _buildCorrespondentFormField() - .paddedSymmetrically(vertical: 8, horizontal: 16), - _buildDocumentTypeFormField() - .paddedSymmetrically(vertical: 8, horizontal: 16), - _buildStoragePathFormField() - .paddedSymmetrically(vertical: 8, horizontal: 16), - _buildTagsFormField().padded(16), - ].map((w) => SliverToBoxAdapter(child: w)).toList(); - } - Container _buildDragHandle() { return Container( // According to m3 spec https://m3.material.io/components/bottom-sheets/specs @@ -212,19 +164,6 @@ class _DocumentFilterPanelState extends State { ); } - BlocBuilder, LabelState> _buildTagsFormField() { - return BlocBuilder, LabelState>( - builder: (context, state) { - return TagFormField( - name: DocumentModel.tagsKey, - initialValue: widget.initialFilter.tags, - allowCreation: false, - selectableOptions: state.labels, - ); - }, - ); - } - void _resetFilter() async { FocusScope.of(context).unfocus(); Navigator.pop( @@ -233,102 +172,13 @@ class _DocumentFilterPanelState extends State { ); } - Widget _buildDocumentTypeFormField() { - return BlocBuilder, LabelState>( - builder: (context, state) { - return LabelFormField( - formBuilderState: _formKey.currentState, - name: fkDocumentType, - labelOptions: state.labels, - textFieldLabel: S.of(context).documentDocumentTypePropertyLabel, - initialValue: widget.initialFilter.documentType, - prefixIcon: const Icon(Icons.description_outlined), - ); - }, - ); - } - - Widget _buildCorrespondentFormField() { - return BlocBuilder, LabelState>( - builder: (context, state) { - return LabelFormField( - formBuilderState: _formKey.currentState, - name: fkCorrespondent, - labelOptions: state.labels, - textFieldLabel: S.of(context).documentCorrespondentPropertyLabel, - initialValue: widget.initialFilter.correspondent, - prefixIcon: const Icon(Icons.person_outline), - ); - }, - ); - } - - Widget _buildStoragePathFormField() { - return BlocBuilder, LabelState>( - builder: (context, state) { - return LabelFormField( - formBuilderState: _formKey.currentState, - name: fkStoragePath, - labelOptions: state.labels, - textFieldLabel: S.of(context).documentStoragePathPropertyLabel, - initialValue: widget.initialFilter.storagePath, - prefixIcon: const Icon(Icons.folder_outlined), - ); - }, - ); - } - - Widget _buildQueryFormField() { - return TextQueryFormField( - name: fkQuery, - onlyExtendedQueryAllowed: _allowOnlyExtendedQuery, - initialValue: widget.initialFilter.query, - ); - } - void _onApplyFilter() async { _formKey.currentState?.save(); if (_formKey.currentState?.validate() ?? false) { - DocumentFilter newFilter = _assembleFilter(); + DocumentFilter newFilter = + DocumentFilterForm.assembleFilter(_formKey, widget.initialFilter); FocusScope.of(context).unfocus(); Navigator.pop(context, DocumentFilterIntent(filter: newFilter)); } } - - DocumentFilter _assembleFilter() { - _formKey.currentState?.save(); - final v = _formKey.currentState!.value; - return DocumentFilter( - correspondent: v[fkCorrespondent] as IdQueryParameter? ?? - DocumentFilter.initial.correspondent, - documentType: v[fkDocumentType] as IdQueryParameter? ?? - DocumentFilter.initial.documentType, - storagePath: v[fkStoragePath] as IdQueryParameter? ?? - DocumentFilter.initial.storagePath, - tags: - v[DocumentModel.tagsKey] as TagsQuery? ?? DocumentFilter.initial.tags, - query: v[fkQuery] as TextQuery? ?? DocumentFilter.initial.query, - created: (v[fkCreatedAt] as DateRangeQuery), - added: (v[fkAddedAt] as DateRangeQuery), - asnQuery: widget.initialFilter.asnQuery, - page: 1, - pageSize: widget.initialFilter.pageSize, - sortField: widget.initialFilter.sortField, - sortOrder: widget.initialFilter.sortOrder, - ); - } - - void _checkQueryConstraints() { - final filter = _assembleFilter(); - if (filter.forceExtendedQuery) { - setState(() => _allowOnlyExtendedQuery = true); - final queryField = _formKey.currentState?.fields[fkQuery]; - queryField?.didChange( - (queryField.value as TextQuery?) - ?.copyWith(queryType: QueryType.extended), - ); - } else { - setState(() => _allowOnlyExtendedQuery = false); - } - } } diff --git a/lib/features/documents/view/widgets/view_actions.dart b/lib/features/documents/view/widgets/view_actions.dart new file mode 100644 index 0000000..a69feb0 --- /dev/null +++ b/lib/features/documents/view/widgets/view_actions.dart @@ -0,0 +1,39 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:paperless_mobile/features/documents/view/widgets/sort_documents_button.dart'; +import 'package:paperless_mobile/features/settings/bloc/application_settings_cubit.dart'; +import 'package:paperless_mobile/features/settings/bloc/application_settings_state.dart'; +import 'package:paperless_mobile/features/settings/model/view_type.dart'; + +class ViewActions extends StatelessWidget { + const ViewActions({super.key}); + + @override + Widget build(BuildContext context) { + return Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + const SortDocumentsButton(), + BlocBuilder( + builder: (context, settings) { + final cubit = context.read(); + switch (settings.preferredViewType) { + case ViewType.grid: + return IconButton( + icon: const Icon(Icons.list), + onPressed: () => + cubit.setViewType(settings.preferredViewType.toggle()), + ); + case ViewType.list: + return IconButton( + icon: const Icon(Icons.grid_view_rounded), + onPressed: () => + cubit.setViewType(settings.preferredViewType.toggle()), + ); + } + }, + ) + ], + ); + } +} diff --git a/lib/features/inbox/view/pages/inbox_page.dart b/lib/features/inbox/view/pages/inbox_page.dart index 9028c2d..d70f5f2 100644 --- a/lib/features/inbox/view/pages/inbox_page.dart +++ b/lib/features/inbox/view/pages/inbox_page.dart @@ -3,7 +3,7 @@ import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:intl/intl.dart'; import 'package:paperless_api/paperless_api.dart'; -import 'package:paperless_mobile/core/widgets/documents_list_loading_widget.dart'; +import 'package:paperless_mobile/features/documents/view/widgets/documents_list_loading_widget.dart'; import 'package:paperless_mobile/core/widgets/hint_card.dart'; import 'package:paperless_mobile/extensions/dart_extensions.dart'; import 'package:paperless_mobile/extensions/flutter_extensions.dart'; diff --git a/lib/features/linked_documents/view/pages/linked_documents_page.dart b/lib/features/linked_documents/view/pages/linked_documents_page.dart index b741104..b0956c2 100644 --- a/lib/features/linked_documents/view/pages/linked_documents_page.dart +++ b/lib/features/linked_documents/view/pages/linked_documents_page.dart @@ -1,10 +1,12 @@ 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/documents_list_loading_widget.dart'; +import 'package:paperless_mobile/core/bloc/connectivity_cubit.dart'; +import 'package:paperless_mobile/features/documents/view/widgets/documents_list_loading_widget.dart'; import 'package:paperless_mobile/features/document_details/bloc/document_details_cubit.dart'; import 'package:paperless_mobile/features/document_details/view/pages/document_details_page.dart'; -import 'package:paperless_mobile/features/documents/view/widgets/list/document_list_item.dart'; +import 'package:paperless_mobile/features/documents/view/widgets/adaptive_documents_view.dart'; +import 'package:paperless_mobile/features/documents/view/widgets/items/document_list_item.dart'; import 'package:paperless_mobile/features/linked_documents/bloc/linked_documents_cubit.dart'; import 'package:paperless_mobile/features/linked_documents/bloc/state/linked_documents_state.dart'; import 'package:paperless_mobile/generated/l10n.dart'; @@ -48,14 +50,17 @@ class _LinkedDocumentsPageState extends State { ), body: BlocBuilder( builder: (context, state) { - if (!state.hasLoaded) { - return const DocumentsListLoadingWidget(); - } - return ListView.builder( - itemCount: state.documents.length, - itemBuilder: (context, index) => DocumentListItem( - document: state.documents[index], - ), + return BlocBuilder( + builder: (context, connectivity) { + return DefaultAdaptiveDocumentsView( + scrollController: _scrollController, + documents: state.documents, + hasInternetConnection: connectivity.isConnected, + isLabelClickable: false, + isLoading: state.isLoading, + hasLoaded: state.hasLoaded, + ); + }, ); }, ), diff --git a/lib/features/saved_view/cubit/saved_view_details_cubit.dart b/lib/features/saved_view/cubit/saved_view_details_cubit.dart new file mode 100644 index 0000000..9b2dfd7 --- /dev/null +++ b/lib/features/saved_view/cubit/saved_view_details_cubit.dart @@ -0,0 +1,20 @@ +import 'package:bloc/bloc.dart'; +import 'package:paperless_api/paperless_api.dart'; +import 'package:paperless_mobile/features/paged_document_view/model/paged_documents_state.dart'; +import 'package:paperless_mobile/features/paged_document_view/paged_documents_mixin.dart'; + +part 'saved_view_details_state.dart'; + +class SavedViewDetailsCubit extends Cubit + with PagedDocumentsMixin { + @override + final PaperlessDocumentsApi api; + + final SavedView savedView; + SavedViewDetailsCubit( + this.api, { + required this.savedView, + }) : super(const SavedViewDetailsState()) { + updateFilter(filter: savedView.toDocumentFilter()); + } +} diff --git a/lib/features/saved_view/cubit/saved_view_details_state.dart b/lib/features/saved_view/cubit/saved_view_details_state.dart new file mode 100644 index 0000000..653d9e4 --- /dev/null +++ b/lib/features/saved_view/cubit/saved_view_details_state.dart @@ -0,0 +1,47 @@ +part of 'saved_view_details_cubit.dart'; + +class SavedViewDetailsState extends PagedDocumentsState { + const SavedViewDetailsState({ + super.filter, + super.hasLoaded, + super.isLoading, + super.value, + }); + + @override + List get props => [ + filter, + hasLoaded, + isLoading, + value, + ]; + + @override + SavedViewDetailsState copyWithPaged({ + bool? hasLoaded, + bool? isLoading, + List>? value, + DocumentFilter? filter, + }) { + return copyWith( + hasLoaded: hasLoaded, + isLoading: isLoading, + value: value, + filter: filter, + ); + } + + SavedViewDetailsState copyWith({ + bool? hasLoaded, + bool? isLoading, + List>? value, + DocumentFilter? filter, + }) { + return SavedViewDetailsState( + hasLoaded: hasLoaded ?? this.hasLoaded, + isLoading: isLoading ?? this.isLoading, + value: value ?? this.value, + filter: filter ?? this.filter, + ); + } +} diff --git a/lib/features/saved_view/view/add_saved_view_page.dart b/lib/features/saved_view/view/add_saved_view_page.dart index 761668e..1e1d8ce 100644 --- a/lib/features/saved_view/view/add_saved_view_page.dart +++ b/lib/features/saved_view/view/add_saved_view_page.dart @@ -1,6 +1,9 @@ import 'package:flutter/material.dart'; import 'package:flutter_form_builder/flutter_form_builder.dart'; import 'package:paperless_api/paperless_api.dart'; +import 'package:paperless_mobile/extensions/flutter_extensions.dart'; +import 'package:paperless_mobile/features/documents/view/widgets/search/document_filter_form.dart'; +import 'package:paperless_mobile/features/labels/bloc/providers/labels_bloc_provider.dart'; import 'package:paperless_mobile/generated/l10n.dart'; import 'package:form_builder_validators/form_builder_validators.dart'; @@ -17,21 +20,13 @@ class _AddSavedViewPageState extends State { static const fkShowOnDashboard = 'show_on_dashboard'; static const fkShowInSidebar = 'show_in_sidebar'; - final GlobalKey _formKey = GlobalKey(); + final _savedViewFormKey = GlobalKey(); + final _filterFormKey = GlobalKey(); @override Widget build(BuildContext context) { return Scaffold( appBar: AppBar( title: Text(S.of(context).savedViewCreateNewLabel), - actions: [ - Padding( - padding: const EdgeInsets.all(8.0), - child: Tooltip( - child: const Icon(Icons.info_outline), - message: S.of(context).savedViewCreateTooltipText, - ), - ), - ], ), floatingActionButton: FloatingActionButton.extended( icon: const Icon(Icons.add), @@ -40,44 +35,102 @@ class _AddSavedViewPageState extends State { ), body: Padding( padding: const EdgeInsets.all(8.0), - child: FormBuilder( - key: _formKey, - child: ListView( - children: [ - FormBuilderTextField( - name: fkName, - validator: FormBuilderValidators.required(), - decoration: InputDecoration( - label: Text(S.of(context).savedViewNameLabel), - ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisAlignment: MainAxisAlignment.start, + children: [ + FormBuilder( + key: _savedViewFormKey, + child: Column( + children: [ + FormBuilderTextField( + name: _AddSavedViewPageState.fkName, + validator: FormBuilderValidators.required(), + decoration: InputDecoration( + label: Text(S.of(context).savedViewNameLabel), + ), + ), + FormBuilderCheckbox( + name: _AddSavedViewPageState.fkShowOnDashboard, + initialValue: false, + title: Text(S.of(context).savedViewShowOnDashboardLabel), + ), + FormBuilderCheckbox( + name: _AddSavedViewPageState.fkShowInSidebar, + initialValue: false, + title: Text(S.of(context).savedViewShowInSidebarLabel), + ), + ], ), - FormBuilderCheckbox( - name: fkShowOnDashboard, - initialValue: false, - title: Text(S.of(context).savedViewShowOnDashboardLabel), + ), + Divider(), + Text( + "Review filter", + style: Theme.of(context).textTheme.bodyLarge, + ).padded(), + Flexible( + child: DocumentFilterForm( + padding: const EdgeInsets.symmetric(vertical: 8), + formKey: _filterFormKey, + initialFilter: widget.currentFilter, ), - FormBuilderCheckbox( - name: fkShowInSidebar, - initialValue: false, - title: Text(S.of(context).savedViewShowInSidebarLabel), - ), - ], - ), + ), + ], ), ), ); } + Padding _buildOld(BuildContext context) { + return Padding( + padding: const EdgeInsets.all(8.0), + child: Column( + children: [ + FormBuilder( + key: _savedViewFormKey, + child: Expanded( + child: ListView( + children: [ + FormBuilderTextField( + name: fkName, + validator: FormBuilderValidators.required(), + decoration: InputDecoration( + label: Text(S.of(context).savedViewNameLabel), + ), + ), + FormBuilderCheckbox( + name: fkShowOnDashboard, + initialValue: false, + title: Text(S.of(context).savedViewShowOnDashboardLabel), + ), + FormBuilderCheckbox( + name: fkShowInSidebar, + initialValue: false, + title: Text(S.of(context).savedViewShowInSidebarLabel), + ), + ], + ), + ), + ), + ], + ), + ); + } + void _onCreate(BuildContext context) { - if (_formKey.currentState?.saveAndValidate() ?? false) { + if (_savedViewFormKey.currentState?.saveAndValidate() ?? false) { Navigator.pop( context, SavedView.fromDocumentFilter( - widget.currentFilter, - name: _formKey.currentState?.value[fkName] as String, + DocumentFilterForm.assembleFilter( + _filterFormKey, + widget.currentFilter, + ), + name: _savedViewFormKey.currentState?.value[fkName] as String, showOnDashboard: - _formKey.currentState?.value[fkShowOnDashboard] as bool, - showInSidebar: _formKey.currentState?.value[fkShowInSidebar] as bool, + _savedViewFormKey.currentState?.value[fkShowOnDashboard] as bool, + showInSidebar: + _savedViewFormKey.currentState?.value[fkShowInSidebar] as bool, ), ); } diff --git a/lib/features/saved_view/view/saved_view_list.dart b/lib/features/saved_view/view/saved_view_list.dart new file mode 100644 index 0000000..04f09ef --- /dev/null +++ b/lib/features/saved_view/view/saved_view_list.dart @@ -0,0 +1,61 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:paperless_mobile/extensions/flutter_extensions.dart'; +import 'package:paperless_mobile/features/documents/view/widgets/documents_empty_state.dart'; +import 'package:paperless_mobile/features/saved_view/cubit/saved_view_cubit.dart'; +import 'package:paperless_mobile/features/saved_view/cubit/saved_view_details_cubit.dart'; +import 'package:paperless_mobile/features/saved_view/cubit/saved_view_state.dart'; +import 'package:paperless_mobile/features/saved_view/view/saved_view_page.dart'; +import 'package:paperless_mobile/generated/l10n.dart'; + +class SavedViewList extends StatelessWidget { + const SavedViewList({super.key}); + + @override + Widget build(BuildContext context) { + final savedViewCubit = context.read(); + return BlocBuilder( + builder: (context, state) { + if (state.value.isEmpty) { + return Text( + S.of(context).savedViewsEmptyStateText, + textAlign: TextAlign.center, + ).padded(); + } + return SliverList( + delegate: SliverChildBuilderDelegate( + (context, index) { + final view = state.value.values.elementAt(index); + return ListTile( + title: Text(view.name), + subtitle: Text( + "${view.filterRules.length} filter(s) set"), //TODO: INTL w/ placeholder + onTap: () { + Navigator.of(context).push( + MaterialPageRoute( + builder: (context) => MultiBlocProvider( + providers: [ + BlocProvider( + create: (context) => SavedViewDetailsCubit( + context.read(), + savedView: view, + ), + ), + BlocProvider.value(value: savedViewCubit), + ], + child: SavedViewPage( + onDelete: savedViewCubit.remove, + ), + ), + ), + ); + }, + ); + }, + childCount: state.value.length, + ), + ); + }, + ); + } +} diff --git a/lib/features/saved_view/view/saved_view_page.dart b/lib/features/saved_view/view/saved_view_page.dart new file mode 100644 index 0000000..84f7b20 --- /dev/null +++ b/lib/features/saved_view/view/saved_view_page.dart @@ -0,0 +1,133 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:paperless_api/paperless_api.dart'; +import 'package:paperless_mobile/core/bloc/connectivity_cubit.dart'; +import 'package:paperless_mobile/core/repository/provider/label_repositories_provider.dart'; +import 'package:paperless_mobile/features/document_details/bloc/document_details_cubit.dart'; +import 'package:paperless_mobile/features/document_details/view/pages/document_details_page.dart'; +import 'package:paperless_mobile/features/documents/view/widgets/adaptive_documents_view.dart'; +import 'package:paperless_mobile/features/documents/view/widgets/documents_empty_state.dart'; +import 'package:paperless_mobile/features/documents/view/widgets/selection/confirm_delete_saved_view_dialog.dart'; +import 'package:paperless_mobile/features/documents/view/widgets/view_actions.dart'; +import 'package:paperless_mobile/features/saved_view/cubit/saved_view_details_cubit.dart'; +import 'package:paperless_mobile/features/settings/model/view_type.dart'; +import 'package:paperless_mobile/helpers/message_helpers.dart'; + +class SavedViewPage extends StatefulWidget { + final Future Function(SavedView savedView) onDelete; + const SavedViewPage({ + super.key, + required this.onDelete, + }); + + @override + State createState() => _SavedViewPageState(); +} + +class _SavedViewPageState extends State { + final _scrollController = ScrollController(); + ViewType _viewType = ViewType.list; + SavedView get _savedView => context.read().savedView; + + @override + void initState() { + super.initState(); + _scrollController.addListener(_listenForLoadNewData); + } + + void _listenForLoadNewData() async { + final currState = context.read().state; + if (_scrollController.offset >= + _scrollController.position.maxScrollExtent * 0.7 && + !currState.isLoading && + !currState.isLastPageLoaded) { + try { + await context.read().loadMore(); + } on PaperlessServerException catch (error, stackTrace) { + showErrorMessage(context, error, stackTrace); + } + } + } + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar( + title: BlocBuilder( + builder: (context, state) { + return Text(_savedView.name); + }, + ), + actions: [ + IconButton( + icon: const Icon(Icons.delete), + onPressed: () async { + final shouldDelete = await showDialog( + context: context, + builder: (context) => + ConfirmDeleteSavedViewDialog(view: _savedView), + ) ?? + false; + if (shouldDelete) { + await widget.onDelete(_savedView); + Navigator.pop(context); + } + }, + ), + IconButton( + icon: Icon( + _viewType == ViewType.list ? Icons.grid_view_rounded : Icons.list, + ), + onPressed: () => setState(() => _viewType = _viewType.toggle()), + ), + ], + ), + body: BlocBuilder( + builder: (context, state) { + if (state.hasLoaded && state.documents.isEmpty) { + return DocumentsEmptyState(state: state); + } + return BlocBuilder( + builder: (context, connectivity) { + return CustomScrollView( + controller: _scrollController, + slivers: [ + SliverAdaptiveDocumentsView( + documents: state.documents, + hasInternetConnection: connectivity.isConnected, + isLabelClickable: false, + isLoading: state.isLoading, + hasLoaded: state.hasLoaded, + onTap: _onOpenDocumentDetails, + viewType: _viewType, + ), + ], + ); + }, + ); + }, + ), + ); + } + + void _onOpenDocumentDetails(DocumentModel document) async { + final updatedDocument = await Navigator.push( + context, + MaterialPageRoute( + builder: (_) => BlocProvider( + create: (context) => DocumentDetailsCubit( + context.read(), + document, + ), + child: const LabelRepositoriesProvider( + child: DocumentDetailsPage(), + ), + ), + ), + ); + if (updatedDocument != document) { + // Reload in case document was edited and might not fulfill filter criteria of saved view anymore + context.read().reload(); + } + } +} diff --git a/lib/features/saved_view/view/saved_view_selection_widget.dart b/lib/features/saved_view/view/saved_view_selection_widget.dart index bbc21b8..43d71b3 100644 --- a/lib/features/saved_view/view/saved_view_selection_widget.dart +++ b/lib/features/saved_view/view/saved_view_selection_widget.dart @@ -1,218 +1,218 @@ -import 'dart:math'; +// import 'dart:math'; -import 'package:flutter/material.dart'; -import 'package:flutter_bloc/flutter_bloc.dart'; -import 'package:paperless_api/paperless_api.dart'; -import 'package:paperless_mobile/core/bloc/connectivity_cubit.dart'; -import 'package:paperless_mobile/extensions/flutter_extensions.dart'; -import 'package:paperless_mobile/features/documents/bloc/documents_cubit.dart'; -import 'package:paperless_mobile/features/documents/bloc/documents_state.dart'; -import 'package:paperless_mobile/features/documents/view/widgets/selection/confirm_delete_saved_view_dialog.dart'; -import 'package:paperless_mobile/features/saved_view/cubit/saved_view_cubit.dart'; -import 'package:paperless_mobile/features/saved_view/cubit/saved_view_state.dart'; -import 'package:paperless_mobile/features/saved_view/view/add_saved_view_page.dart'; -import 'package:paperless_mobile/generated/l10n.dart'; -import 'package:paperless_mobile/helpers/message_helpers.dart'; -import 'package:paperless_mobile/constants.dart'; -import 'package:shimmer/shimmer.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/bloc/connectivity_cubit.dart'; +// import 'package:paperless_mobile/extensions/flutter_extensions.dart'; +// import 'package:paperless_mobile/features/documents/bloc/documents_cubit.dart'; +// import 'package:paperless_mobile/features/documents/bloc/documents_state.dart'; +// import 'package:paperless_mobile/features/documents/view/widgets/selection/confirm_delete_saved_view_dialog.dart'; +// import 'package:paperless_mobile/features/saved_view/cubit/saved_view_cubit.dart'; +// import 'package:paperless_mobile/features/saved_view/cubit/saved_view_state.dart'; +// import 'package:paperless_mobile/features/saved_view/view/add_saved_view_page.dart'; +// import 'package:paperless_mobile/generated/l10n.dart'; +// import 'package:paperless_mobile/helpers/message_helpers.dart'; +// import 'package:paperless_mobile/constants.dart'; +// import 'package:shimmer/shimmer.dart'; -class SavedViewSelectionWidget extends StatelessWidget { - final DocumentFilter currentFilter; - const SavedViewSelectionWidget({ - Key? key, - required this.height, - required this.enabled, - required this.currentFilter, - }) : super(key: key); +// class SavedViewSelectionWidget extends StatelessWidget { +// final DocumentFilter currentFilter; +// const SavedViewSelectionWidget({ +// Key? key, +// required this.height, +// required this.enabled, +// required this.currentFilter, +// }) : super(key: key); - final double height; - final bool enabled; +// final double height; +// final bool enabled; - @override - Widget build(BuildContext context) { - return BlocBuilder( - builder: (context, connectivityState) { - final hasInternetConnection = connectivityState.isConnected; - return SizedBox( - height: height, - child: Column( - mainAxisAlignment: MainAxisAlignment.start, - crossAxisAlignment: CrossAxisAlignment.start, - mainAxisSize: MainAxisSize.min, - children: [ - BlocBuilder( - builder: (context, state) { - if (!state.hasLoaded) { - return _buildLoadingWidget(context); - } - if (state.value.isEmpty) { - return Text(S.of(context).savedViewsEmptyStateText); - } - return SizedBox( - height: 38, - child: ListView.separated( - itemCount: state.value.length, - scrollDirection: Axis.horizontal, - itemBuilder: (context, index) { - final view = state.value.values.elementAt(index); - return GestureDetector( - onLongPress: hasInternetConnection - ? () => _onDelete(context, view) - : null, - child: BlocBuilder( - builder: (context, docState) { - final view = state.value.values.toList()[index]; - return FilterChip( - label: Text( - view.name, - ), - selected: - view.id == docState.selectedSavedViewId, - onSelected: enabled && hasInternetConnection - ? (isSelected) => - _onSelected(isSelected, context, view) - : null, - ); - }, - ), - ); - }, - separatorBuilder: (context, index) => const SizedBox( - width: 4.0, - ), - ), - ); - }, - ), - BlocBuilder( - builder: (context, state) { - return Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, - children: [ - Text( - S.of(context).savedViewsLabel, - style: Theme.of(context).textTheme.titleSmall, - ), - BlocBuilder( - buildWhen: (previous, current) => - previous.filter != current.filter, - builder: (context, docState) { - return TextButton.icon( - icon: const Icon(Icons.add), - onPressed: (enabled && - state.hasLoaded && - hasInternetConnection) - ? () => - _onCreatePressed(context, docState.filter) - : null, - label: Text(S.of(context).savedViewCreateNewLabel), - ); - }, - ), - ], - ); - }, - ), - ], - ).padded(), - ); - }, - ); - } +// @override +// Widget build(BuildContext context) { +// return BlocBuilder( +// builder: (context, connectivityState) { +// final hasInternetConnection = connectivityState.isConnected; +// return SizedBox( +// height: height, +// child: Column( +// mainAxisAlignment: MainAxisAlignment.start, +// crossAxisAlignment: CrossAxisAlignment.start, +// mainAxisSize: MainAxisSize.min, +// children: [ +// BlocBuilder( +// builder: (context, state) { +// if (!state.hasLoaded) { +// return _buildLoadingWidget(context); +// } +// if (state.value.isEmpty) { +// return Text(S.of(context).savedViewsEmptyStateText); +// } +// return SizedBox( +// height: 38, +// child: ListView.separated( +// itemCount: state.value.length, +// scrollDirection: Axis.horizontal, +// itemBuilder: (context, index) { +// final view = state.value.values.elementAt(index); +// return GestureDetector( +// onLongPress: hasInternetConnection +// ? () => _onDelete(context, view) +// : null, +// child: BlocBuilder( +// builder: (context, docState) { +// final view = state.value.values.toList()[index]; +// return FilterChip( +// label: Text( +// view.name, +// ), +// selected: +// view.id == docState.selectedSavedViewId, +// onSelected: enabled && hasInternetConnection +// ? (isSelected) => +// _onSelected(isSelected, context, view) +// : null, +// ); +// }, +// ), +// ); +// }, +// separatorBuilder: (context, index) => const SizedBox( +// width: 4.0, +// ), +// ), +// ); +// }, +// ), +// BlocBuilder( +// builder: (context, state) { +// return Row( +// mainAxisAlignment: MainAxisAlignment.spaceBetween, +// children: [ +// Text( +// S.of(context).savedViewsLabel, +// style: Theme.of(context).textTheme.titleSmall, +// ), +// BlocBuilder( +// buildWhen: (previous, current) => +// previous.filter != current.filter, +// builder: (context, docState) { +// return TextButton.icon( +// icon: const Icon(Icons.add), +// onPressed: (enabled && +// state.hasLoaded && +// hasInternetConnection) +// ? () => +// _onCreatePressed(context, docState.filter) +// : null, +// label: Text(S.of(context).savedViewCreateNewLabel), +// ); +// }, +// ), +// ], +// ); +// }, +// ), +// ], +// ).padded(), +// ); +// }, +// ); +// } - Widget _buildLoadingWidget(BuildContext context) { - return SizedBox( - height: 38, - width: MediaQuery.of(context).size.width, - child: Shimmer.fromColors( - baseColor: Theme.of(context).brightness == Brightness.light - ? Colors.grey[300]! - : Colors.grey[900]!, - highlightColor: Theme.of(context).brightness == Brightness.light - ? Colors.grey[100]! - : Colors.grey[600]!, - child: ListView( - scrollDirection: Axis.horizontal, - physics: const NeverScrollableScrollPhysics(), - children: [ - FilterChip( - label: const SizedBox(width: 32), - onSelected: (_) {}, - ), - const SizedBox(width: 4.0), - FilterChip( - label: const SizedBox(width: 64), - onSelected: (_) {}, - ), - const SizedBox(width: 4.0), - FilterChip( - label: const SizedBox(width: 100), - onSelected: (_) {}, - ), - const SizedBox(width: 4.0), - FilterChip( - label: const SizedBox(width: 32), - onSelected: (_) {}, - ), - const SizedBox(width: 4.0), - FilterChip( - label: const SizedBox(width: 48), - onSelected: (_) {}, - ), - ], - ), - ), - ); - } +// Widget _buildLoadingWidget(BuildContext context) { +// return SizedBox( +// height: 38, +// width: MediaQuery.of(context).size.width, +// child: Shimmer.fromColors( +// baseColor: Theme.of(context).brightness == Brightness.light +// ? Colors.grey[300]! +// : Colors.grey[900]!, +// highlightColor: Theme.of(context).brightness == Brightness.light +// ? Colors.grey[100]! +// : Colors.grey[600]!, +// child: ListView( +// scrollDirection: Axis.horizontal, +// physics: const NeverScrollableScrollPhysics(), +// children: [ +// FilterChip( +// label: const SizedBox(width: 32), +// onSelected: (_) {}, +// ), +// const SizedBox(width: 4.0), +// FilterChip( +// label: const SizedBox(width: 64), +// onSelected: (_) {}, +// ), +// const SizedBox(width: 4.0), +// FilterChip( +// label: const SizedBox(width: 100), +// onSelected: (_) {}, +// ), +// const SizedBox(width: 4.0), +// FilterChip( +// label: const SizedBox(width: 32), +// onSelected: (_) {}, +// ), +// const SizedBox(width: 4.0), +// FilterChip( +// label: const SizedBox(width: 48), +// onSelected: (_) {}, +// ), +// ], +// ), +// ), +// ); +// } - void _onCreatePressed(BuildContext context, DocumentFilter filter) async { - final newView = await Navigator.of(context).push( - MaterialPageRoute( - builder: (context) => AddSavedViewPage( - currentFilter: filter, - ), - ), - ); - if (newView != null) { - try { - await context.read().add(newView); - } on PaperlessServerException catch (error, stackTrace) { - showErrorMessage(context, error, stackTrace); - } - } - } +// void _onCreatePressed(BuildContext context, DocumentFilter filter) async { +// final newView = await Navigator.of(context).push( +// MaterialPageRoute( +// builder: (context) => AddSavedViewPage( +// currentFilter: filter, +// ), +// ), +// ); +// if (newView != null) { +// try { +// await context.read().add(newView); +// } on PaperlessServerException catch (error, stackTrace) { +// showErrorMessage(context, error, stackTrace); +// } +// } +// } - void _onSelected( - bool selectionIntent, - BuildContext context, - SavedView view, - ) async { - if (selectionIntent) { - context.read().selectView(view.id!); - } else { - context.read().unselectView(); - context.read().resetFilter(); - } - } +// void _onSelected( +// bool selectionIntent, +// BuildContext context, +// SavedView view, +// ) async { +// if (selectionIntent) { +// context.read().selectView(view.id!); +// } else { +// context.read().unselectView(); +// context.read().resetFilter(); +// } +// } - void _onDelete(BuildContext context, SavedView view) async { - { - final delete = await showDialog( - context: context, - builder: (context) => ConfirmDeleteSavedViewDialog(view: view), - ) ?? - false; - if (delete) { - try { - context.read().remove(view); - if (context.read().state.selectedSavedViewId == - view.id) { - await context.read().resetFilter(); - } - } on PaperlessServerException catch (error, stackTrace) { - showErrorMessage(context, error, stackTrace); - } - } - } - } -} +// void _onDelete(BuildContext context, SavedView view) async { +// { +// final delete = await showDialog( +// context: context, +// builder: (context) => ConfirmDeleteSavedViewDialog(view: view), +// ) ?? +// false; +// if (delete) { +// try { +// context.read().remove(view); +// if (context.read().state.selectedSavedViewId == +// view.id) { +// await context.read().resetFilter(); +// } +// } on PaperlessServerException catch (error, stackTrace) { +// showErrorMessage(context, error, stackTrace); +// } +// } +// } +// } +// } diff --git a/lib/features/search/view/document_search_page.dart b/lib/features/search/view/document_search_page.dart index 2bedd3d..a7255a2 100644 --- a/lib/features/search/view/document_search_page.dart +++ b/lib/features/search/view/document_search_page.dart @@ -1,13 +1,10 @@ import 'package:collection/collection.dart'; -import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; -import 'package:flutter/widgets.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; -import 'package:paperless_mobile/core/widgets/documents_list_loading_widget.dart'; import 'package:paperless_mobile/extensions/flutter_extensions.dart'; -import 'package:paperless_mobile/features/documents/view/widgets/list/document_list_item.dart'; -import 'package:paperless_mobile/features/search/cubit/document_search_state.dart'; +import 'package:paperless_mobile/features/documents/view/widgets/adaptive_documents_view.dart'; import 'package:paperless_mobile/features/search/cubit/document_search_cubit.dart'; +import 'package:paperless_mobile/features/search/cubit/document_search_state.dart'; import 'package:paperless_mobile/generated/l10n.dart'; Future showDocumentSearchPage(BuildContext context) { @@ -48,28 +45,33 @@ class _DocumentSearchPageState extends State { color: theme.colorScheme.onSurface, ), decoration: InputDecoration( + contentPadding: EdgeInsets.zero, hintStyle: theme.textTheme.bodyLarge?.apply( color: theme.colorScheme.onSurfaceVariant, ), - hintText: "Search documents", + hintText: "Search documents", //TODO: INTL border: InputBorder.none, ), controller: _queryController, onChanged: context.read().suggest, - onSubmitted: context.read().search, + textInputAction: TextInputAction.search, + onSubmitted: (query) { + FocusScope.of(context).unfocus(); + context.read().search(query); + }, ), actions: [ IconButton( color: theme.colorScheme.onSurfaceVariant, - icon: Icon(Icons.clear), + icon: const Icon(Icons.clear), onPressed: () { context.read().reset(); _queryController.clear(); }, - ) + ).padded(), ], bottom: PreferredSize( - preferredSize: Size.fromHeight(1), + preferredSize: const Size.fromHeight(1), child: Divider( color: theme.colorScheme.outline, ), @@ -103,7 +105,7 @@ class _DocumentSearchPageState extends State { delegate: SliverChildBuilderDelegate( (context, index) => ListTile( title: Text(historyMatches[index]), - leading: Icon(Icons.history), + leading: const Icon(Icons.history), onTap: () => _selectSuggestion(historyMatches[index]), ), childCount: historyMatches.length, @@ -120,7 +122,7 @@ class _DocumentSearchPageState extends State { delegate: SliverChildBuilderDelegate( (context, index) => ListTile( title: Text(suggestions[index]), - leading: Icon(Icons.search), + leading: const Icon(Icons.search), onTap: () => _selectSuggestion(suggestions[index]), ), childCount: suggestions.length, @@ -135,27 +137,21 @@ class _DocumentSearchPageState extends State { S.of(context).documentSearchResults, style: Theme.of(context).textTheme.labelSmall, ).padded(); - if (state.isLoading) { - return DocumentsListLoadingWidget( - beforeWidgets: [header], - ); - } return CustomScrollView( slivers: [ SliverToBoxAdapter(child: header), if (state.hasLoaded && !state.isLoading && state.documents.isEmpty) - SliverToBoxAdapter( - child: Center(child: Text("No documents found.")), + const SliverToBoxAdapter( + child: Center(child: Text("No documents found.")), //TODO: INTL ) else - SliverList( - delegate: SliverChildBuilderDelegate( - (context, index) => DocumentListItem( - document: state.documents[index], - ), - childCount: state.documents.length, - ), - ), + SliverAdaptiveDocumentsView( + documents: state.documents, + hasInternetConnection: true, + isLabelClickable: false, + isLoading: state.isLoading, + hasLoaded: state.hasLoaded, + ) ], ); } diff --git a/lib/features/search/view/documents_search_app_bar.dart b/lib/features/search/view/documents_search_app_bar.dart new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/lib/features/search/view/documents_search_app_bar.dart @@ -0,0 +1 @@ + diff --git a/lib/features/search_app_bar/view/search_app_bar.dart b/lib/features/search_app_bar/view/search_app_bar.dart new file mode 100644 index 0000000..08ddb62 --- /dev/null +++ b/lib/features/search_app_bar/view/search_app_bar.dart @@ -0,0 +1,53 @@ +import 'package:flutter/material.dart'; +import 'package:paperless_mobile/core/widgets/material/search/m3_search_bar.dart'; +import 'package:paperless_mobile/extensions/flutter_extensions.dart'; + +typedef OpenSearchCallback = void Function(BuildContext context); + +class SearchAppBar extends StatefulWidget with PreferredSizeWidget { + final PreferredSizeWidget? bottom; + final OpenSearchCallback onOpenSearch; + final Color? backgroundColor; + const SearchAppBar({ + super.key, + required this.onOpenSearch, + this.bottom, + this.backgroundColor, + }); + + @override + State createState() => _SearchAppBarState(); + + @override + Size get preferredSize => const Size.fromHeight(kToolbarHeight); +} + +class _SearchAppBarState extends State { + @override + Widget build(BuildContext context) { + return SliverAppBar( + floating: true, + pinned: true, + snap: true, + backgroundColor: widget.backgroundColor, + title: SearchBar( + height: kToolbarHeight - 8, + supportingText: "Search documents", + onTap: () => widget.onOpenSearch(context), + leadingIcon: IconButton( + icon: const Icon(Icons.menu), + onPressed: () { + Scaffold.of(context).openDrawer(); + }, + ), + trailingIcon: IconButton( + icon: const CircleAvatar( + child: Text("A"), + ), + onPressed: () {}, + ), + ).paddedOnly(top: 4, bottom: 4), + bottom: widget.bottom, + ); + } +}