From b697dc7d8db3006376250314e93eb9e3630f81bb Mon Sep 17 00:00:00 2001 From: Anton Stubenbord Date: Sat, 28 Jan 2023 23:06:27 +0100 Subject: [PATCH] WIP - Reimplemented document search --- .../sort_field_localization_mapper.dart | 4 +- lib/core/widgets/app_options_popup_menu.dart | 217 ++++++++++++ .../material/search/m3_search_bar.dart | 77 ++--- .../cubit/document_search_cubit.dart | 56 --- .../document_search_delegate.dart | 206 ----------- .../view/document_search_bar.dart | 49 --- .../documents/bloc/documents_cubit.dart | 4 +- .../documents/bloc/documents_state.dart | 4 +- .../documents/view/pages/documents_page.dart | 324 ++++++++++-------- .../view/widgets/documents_empty_state.dart | 4 +- .../widgets/list/adaptive_documents_view.dart | 4 +- .../view/widgets/sort_documents_button.dart | 87 ++--- lib/features/home/view/home_page.dart | 2 +- lib/features/home/view/widget/app_drawer.dart | 50 +-- lib/features/inbox/bloc/inbox_cubit.dart | 4 +- .../inbox/bloc/state/inbox_state.dart | 4 +- lib/features/inbox/view/pages/inbox_page.dart | 8 +- .../labels/view/pages/labels_page.dart | 1 - .../bloc/linked_documents_cubit.dart | 26 +- .../bloc/state/linked_documents_state.dart | 53 ++- .../view/pages/linked_documents_page.dart | 70 ++-- ..._state.dart => paged_documents_state.dart} | 12 +- ..._mixin.dart => paged_documents_mixin.dart} | 4 +- lib/features/scan/view/scanner_page.dart | 1 - .../search/cubit/document_search_cubit.dart | 68 ++++ .../cubit/document_search_state.dart | 19 +- .../cubit/document_search_state.g.dart | 0 .../search/view/document_search_page.dart | 166 +++++++++ .../bloc/application_settings_state.dart | 12 +- lib/features/settings/view/settings_page.dart | 69 ++++ .../widgets/language_selection_setting.dart | 11 +- .../cubit/similar_documents_cubit.dart | 6 +- .../cubit/similar_documents_state.dart | 2 +- lib/helpers/format_helpers.dart | 2 +- 34 files changed, 949 insertions(+), 677 deletions(-) create mode 100644 lib/core/widgets/app_options_popup_menu.dart delete mode 100644 lib/features/document_search/cubit/document_search_cubit.dart delete mode 100644 lib/features/document_search/document_search_delegate.dart delete mode 100644 lib/features/document_search/view/document_search_bar.dart rename lib/features/paged_document_view/model/{documents_paged_state.dart => paged_documents_state.dart} (88%) rename lib/features/paged_document_view/{documents_paging_mixin.dart => paged_documents_mixin.dart} (97%) create mode 100644 lib/features/search/cubit/document_search_cubit.dart rename lib/features/{document_search => search}/cubit/document_search_state.dart (79%) rename lib/features/{document_search => search}/cubit/document_search_state.g.dart (100%) create mode 100644 lib/features/search/view/document_search_page.dart diff --git a/lib/core/translation/sort_field_localization_mapper.dart b/lib/core/translation/sort_field_localization_mapper.dart index c2b1f0b..e707b6e 100644 --- a/lib/core/translation/sort_field_localization_mapper.dart +++ b/lib/core/translation/sort_field_localization_mapper.dart @@ -2,7 +2,7 @@ import 'package:flutter/material.dart'; import 'package:paperless_api/paperless_api.dart'; import 'package:paperless_mobile/generated/l10n.dart'; -String translateSortField(BuildContext context, SortField sortField) { +String translateSortField(BuildContext context, SortField? sortField) { switch (sortField) { case SortField.archiveSerialNumber: return S.of(context).documentArchiveSerialNumberPropertyShortLabel; @@ -18,5 +18,7 @@ String translateSortField(BuildContext context, SortField sortField) { return S.of(context).documentAddedPropertyLabel; case SortField.modified: return S.of(context).documentModifiedPropertyLabel; + default: + return ''; } } diff --git a/lib/core/widgets/app_options_popup_menu.dart b/lib/core/widgets/app_options_popup_menu.dart new file mode 100644 index 0000000..ba97902 --- /dev/null +++ b/lib/core/widgets/app_options_popup_menu.dart @@ -0,0 +1,217 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:paperless_mobile/constants.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/settings/view/settings_page.dart'; +import 'package:paperless_mobile/generated/l10n.dart'; +import 'package:url_launcher/link.dart'; +import 'package:url_launcher/url_launcher_string.dart'; + +/// Declares selectable actions in menu. +enum AppPopupMenuEntries { + // Documents preview + documentsSelectListView, + documentsSelectGridView, + // Generic actions + openAboutThisAppDialog, + reportBug, + openSettings, + // Adds a divider + divider; +} + +class AppOptionsPopupMenu extends StatelessWidget { + final List displayedActions; + const AppOptionsPopupMenu({ + super.key, + required this.displayedActions, + }); + + @override + Widget build(BuildContext context) { + return PopupMenuButton( + position: PopupMenuPosition.under, + icon: const Icon(Icons.more_vert), + onSelected: (action) { + switch (action) { + case AppPopupMenuEntries.documentsSelectListView: + context.read().setViewType(ViewType.list); + break; + case AppPopupMenuEntries.documentsSelectGridView: + context.read().setViewType(ViewType.grid); + break; + case AppPopupMenuEntries.openAboutThisAppDialog: + _showAboutDialog(context); + break; + case AppPopupMenuEntries.openSettings: + Navigator.of(context).push( + MaterialPageRoute( + builder: (context) => BlocProvider.value( + value: context.read(), + child: const SettingsPage(), + ), + ), + ); + break; + case AppPopupMenuEntries.reportBug: + launchUrlString( + 'https://github.com/astubenbord/paperless-mobile/issues/new', + ); + break; + default: + break; + } + }, + itemBuilder: _buildEntries, + ); + } + + PopupMenuItem _buildReportBugTile(BuildContext context) { + return PopupMenuItem( + value: AppPopupMenuEntries.reportBug, + padding: EdgeInsets.zero, + child: ListTile( + leading: const Icon(Icons.bug_report), + title: Text(S.of(context).appDrawerReportBugLabel), + ), + ); + } + + PopupMenuItem _buildSettingsTile(BuildContext context) { + return PopupMenuItem( + padding: EdgeInsets.zero, + value: AppPopupMenuEntries.openSettings, + child: ListTile( + leading: const Icon(Icons.settings_outlined), + title: Text(S.of(context).appDrawerSettingsLabel), + ), + ); + } + + PopupMenuItem _buildAboutTile(BuildContext context) { + return PopupMenuItem( + padding: EdgeInsets.zero, + value: AppPopupMenuEntries.openAboutThisAppDialog, + child: ListTile( + leading: const Icon(Icons.info_outline), + title: Text(S.of(context).appDrawerAboutLabel), + ), + ); + } + + PopupMenuItem _buildListViewTile() { + return PopupMenuItem( + padding: EdgeInsets.zero, + child: BlocBuilder( + builder: (context, state) { + return ListTile( + leading: const Icon(Icons.list), + title: const Text("List"), + trailing: state.preferredViewType == ViewType.list + ? const Icon(Icons.check) + : null, + ); + }, + ), + value: AppPopupMenuEntries.documentsSelectListView, + ); + } + + PopupMenuItem _buildGridViewTile() { + return PopupMenuItem( + value: AppPopupMenuEntries.documentsSelectGridView, + padding: EdgeInsets.zero, + child: BlocBuilder( + builder: (context, state) { + return ListTile( + leading: const Icon(Icons.grid_view_rounded), + title: const Text("Grid"), + trailing: state.preferredViewType == ViewType.grid + ? const Icon(Icons.check) + : null, + ); + }, + ), + ); + } + + void _showAboutDialog(BuildContext context) { + showAboutDialog( + context: context, + applicationIcon: const ImageIcon( + AssetImage('assets/logos/paperless_logo_green.png'), + ), + applicationName: 'Paperless Mobile', + applicationVersion: packageInfo.version + '+' + packageInfo.buildNumber, + children: [ + Text(S.of(context).aboutDialogDevelopedByText('Anton Stubenbord')), + Link( + uri: Uri.parse('https://github.com/astubenbord/paperless-mobile'), + builder: (context, followLink) => GestureDetector( + onTap: followLink, + child: Text( + 'https://github.com/astubenbord/paperless-mobile', + style: TextStyle(color: Theme.of(context).colorScheme.tertiary), + ), + ), + ), + const SizedBox(height: 16), + Text( + 'Credits', + style: Theme.of(context).textTheme.titleMedium, + ), + _buildOnboardingImageCredits(), + ], + ); + } + + Widget _buildOnboardingImageCredits() { + return Link( + uri: Uri.parse( + 'https://www.freepik.com/free-vector/business-team-working-cogwheel-mechanism-together_8270974.htm#query=setting&position=4&from_view=author'), + builder: (context, followLink) => Wrap( + children: [ + const Text('Onboarding images by '), + GestureDetector( + onTap: followLink, + child: Text( + 'pch.vector', + style: TextStyle(color: Theme.of(context).colorScheme.tertiary), + ), + ), + const Text(' on Freepik.') + ], + ), + ); + } + + List> _buildEntries( + BuildContext context) { + List> items = []; + for (final entry in displayedActions) { + switch (entry) { + case AppPopupMenuEntries.documentsSelectListView: + items.add(_buildListViewTile()); + break; + case AppPopupMenuEntries.documentsSelectGridView: + items.add(_buildGridViewTile()); + break; + case AppPopupMenuEntries.openAboutThisAppDialog: + items.add(_buildAboutTile(context)); + break; + case AppPopupMenuEntries.reportBug: + items.add(_buildReportBugTile(context)); + break; + case AppPopupMenuEntries.openSettings: + items.add(_buildSettingsTile(context)); + break; + case AppPopupMenuEntries.divider: + items.add(const PopupMenuDivider()); + break; + } + } + return items; + } +} diff --git a/lib/core/widgets/material/search/m3_search_bar.dart b/lib/core/widgets/material/search/m3_search_bar.dart index 1ec56ce..eb75144 100644 --- a/lib/core/widgets/material/search/m3_search_bar.dart +++ b/lib/core/widgets/material/search/m3_search_bar.dart @@ -28,51 +28,48 @@ class SearchBar extends StatelessWidget { final ColorScheme colorScheme = Theme.of(context).colorScheme; final TextTheme textTheme = Theme.of(context).textTheme; - return Padding( - padding: const EdgeInsets.symmetric(horizontal: 16), - child: Container( - constraints: const BoxConstraints(minWidth: 360, maxWidth: 720), - width: double.infinity, - height: effectiveHeight, - child: Material( - elevation: 3, - color: colorScheme.surface, - shadowColor: colorScheme.shadow, - surfaceTintColor: colorScheme.surfaceTint, + return Container( + constraints: const BoxConstraints(minWidth: 360, maxWidth: 720), + width: double.infinity, + height: effectiveHeight, + child: Material( + elevation: 1, + color: colorScheme.surface, + shadowColor: colorScheme.shadow, + surfaceTintColor: colorScheme.surfaceTint, + borderRadius: BorderRadius.circular(effectiveHeight / 2), + child: InkWell( + onTap: () {}, borderRadius: BorderRadius.circular(effectiveHeight / 2), - child: InkWell( - onTap: () {}, - borderRadius: BorderRadius.circular(effectiveHeight / 2), - highlightColor: Colors.transparent, - splashFactory: InkRipple.splashFactory, - child: Padding( - padding: const EdgeInsets.symmetric(horizontal: 8), - child: Row(children: [ - leadingIcon, - Expanded( - child: Padding( - padding: const EdgeInsets.only(right: 8), - child: TextField( - cursorColor: colorScheme.primary, - style: textTheme.bodyLarge, - textAlignVertical: TextAlignVertical.center, - decoration: InputDecoration( - isCollapsed: true, - border: InputBorder.none, - contentPadding: - const EdgeInsets.symmetric(horizontal: 8), - hintText: supportingText, - hintStyle: textTheme.bodyLarge?.apply( - color: colorScheme.onSurfaceVariant, - ), + highlightColor: Colors.transparent, + splashFactory: InkRipple.splashFactory, + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 8), + child: Row(children: [ + leadingIcon, + Expanded( + child: Padding( + padding: const EdgeInsets.only(right: 8), + child: TextField( + readOnly: true, + cursorColor: colorScheme.primary, + style: textTheme.bodyLarge, + textAlignVertical: TextAlignVertical.center, + decoration: InputDecoration( + isCollapsed: true, + border: InputBorder.none, + contentPadding: const EdgeInsets.symmetric(horizontal: 8), + hintText: supportingText, + hintStyle: textTheme.bodyLarge?.apply( + color: colorScheme.onSurfaceVariant, ), - onTap: onTap, ), + onTap: onTap, ), ), - if (trailingIcon != null) trailingIcon!, - ]), - ), + ), + if (trailingIcon != null) trailingIcon!, + ]), ), ), ), diff --git a/lib/features/document_search/cubit/document_search_cubit.dart b/lib/features/document_search/cubit/document_search_cubit.dart deleted file mode 100644 index 26d39bc..0000000 --- a/lib/features/document_search/cubit/document_search_cubit.dart +++ /dev/null @@ -1,56 +0,0 @@ -import 'package:collection/collection.dart'; -import 'package:hydrated_bloc/hydrated_bloc.dart'; -import 'package:paperless_api/paperless_api.dart'; -import 'package:paperless_mobile/features/paged_document_view/documents_paging_mixin.dart'; - -import 'document_search_state.dart'; - -class DocumentSearchCubit extends HydratedCubit - with DocumentsPagingMixin { - //// - DocumentSearchCubit(this.api) : super(const DocumentSearchState()); - - @override - final PaperlessDocumentsApi api; - - /// - /// Requests results based on [query] and adds [query] to the - /// search history, removing old occurrences and trimming the list to - /// the last 5 searches. - /// - Future updateResults(String query) async { - await updateFilter( - filter: state.filter.copyWith(query: TextQuery.titleAndContent(query)), - ); - emit( - state.copyWith( - searchHistory: [ - query, - ...state.searchHistory.where((element) => element != query) - ].take(5).toList(), - ), - ); - } - - void removeHistoryEntry(String suggestion) { - emit(state.copyWith( - searchHistory: state.searchHistory - .whereNot((element) => element == suggestion) - .toList(), - )); - } - - Future> findSuggestions(String query) { - return api.autocomplete(query); - } - - @override - DocumentSearchState? fromJson(Map json) { - return DocumentSearchState.fromJson(json); - } - - @override - Map? toJson(DocumentSearchState state) { - return state.toJson(); - } -} diff --git a/lib/features/document_search/document_search_delegate.dart b/lib/features/document_search/document_search_delegate.dart deleted file mode 100644 index 17e8e3b..0000000 --- a/lib/features/document_search/document_search_delegate.dart +++ /dev/null @@ -1,206 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:flutter_bloc/flutter_bloc.dart'; -import 'package:paperless_api/paperless_api.dart'; -import 'package:paperless_mobile/core/repository/provider/label_repositories_provider.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/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/cubit/document_search_state.dart'; -import 'package:paperless_mobile/features/documents/view/widgets/list/document_list_item.dart'; - -import 'package:paperless_mobile/core/widgets/material/search/m3_search.dart' - as m3; -import 'package:paperless_mobile/generated/l10n.dart'; - -class DocumentSearchDelegate extends m3.SearchDelegate { - final DocumentSearchCubit bloc; - DocumentSearchDelegate( - this.bloc, { - required String hintText, - required super.searchFieldStyle, - }) : super( - searchFieldLabel: hintText, - keyboardType: TextInputType.text, - textInputAction: TextInputAction.search, - ); - - @override - Widget buildLeading(BuildContext context) => const BackButton(); - - @override - PreferredSizeWidget buildBottom(BuildContext context) => PreferredSize( - preferredSize: const Size.fromHeight(1), - child: Divider( - color: Theme.of(context).colorScheme.outline, - height: 1, - ), - ); - @override - Widget buildSuggestions(BuildContext context) { - return BlocBuilder( - bloc: bloc, - builder: (context, state) { - if (query.isEmpty) { - return CustomScrollView( - slivers: [ - SliverToBoxAdapter( - child: Text( - S.of(context).documentSearchHistory, - style: Theme.of(context).textTheme.labelMedium, - ).padded(16), - ), - SliverList( - delegate: SliverChildBuilderDelegate( - (context, index) { - final label = state.searchHistory[index]; - return ListTile( - leading: const Icon(Icons.history), - title: Text(label), - onTap: () => _onSuggestionSelected( - context, - label, - ), - onLongPress: () => showDialog( - context: context, - builder: (context) => AlertDialog( - title: Text(label), - content: Text( - S.of(context).documentSearchPageRemoveFromHistory, - ), - actions: [ - TextButton( - child: Text( - S.of(context).genericActionCancelLabel, - ), - onPressed: () => Navigator.pop(context), - ), - TextButton( - child: Text( - S.of(context).genericActionDeleteLabel, - style: TextStyle( - color: Theme.of(context).colorScheme.error, - ), - ), - onPressed: () { - bloc.removeHistoryEntry(label); - Navigator.pop(context); - }, - ), - ], - ), - ), - ); - }, - childCount: state.searchHistory.length, - ), - ), - ], - ); - } - return FutureBuilder>( - future: bloc.findSuggestions(query), - builder: (context, snapshot) { - final historyMatches = state.searchHistory - .where((e) => e.startsWith(query)) - .toList(); - final serverSuggestions = (snapshot.data ?? []) - ..removeWhere((e) => historyMatches.contains(e)); - return CustomScrollView( - slivers: [ - SliverToBoxAdapter( - child: Text( - S.of(context).documentSearchResults, - style: Theme.of(context).textTheme.labelMedium, - ).padded(), - ), - SliverList( - delegate: SliverChildBuilderDelegate( - (context, index) => ListTile( - title: Text(historyMatches[index]), - leading: const Icon(Icons.history), - onTap: () => _onSuggestionSelected( - context, - historyMatches[index], - ), - ), - childCount: historyMatches.length, - ), - ), - SliverList( - delegate: SliverChildBuilderDelegate( - (context, index) => ListTile( - title: Text(serverSuggestions[index]), - leading: const Icon(Icons.search), - onTap: () => _onSuggestionSelected( - context, snapshot.data![index]), - ), - childCount: serverSuggestions.length, - ), - ), - ], - ); - }); - }, - ); - } - - void _onSuggestionSelected(BuildContext context, String suggestion) { - query = suggestion; - bloc.updateResults(query); - super.showResults(context); - } - - @override - Widget buildResults(BuildContext context) { - return BlocBuilder( - bloc: bloc, - builder: (context, state) { - if (!state.hasLoaded && state.isLoading) { - return const DocumentsListLoadingWidget(); - } - return ListView.builder( - itemCount: state.documents.length, - itemBuilder: (context, index) => DocumentListItem( - document: state.documents[index], - onTap: (document) { - Navigator.push( - context, - MaterialPageRoute( - builder: (context) => BlocProvider( - create: (context) => DocumentDetailsCubit( - context.read(), - document, - ), - child: const LabelRepositoriesProvider( - child: DocumentDetailsPage( - isLabelClickable: false, - ), - ), - ), - ), - ); - }, - ), - ); - }, - ); - } - - @override - List? buildActions(BuildContext context) { - return [ - IconButton( - icon: Icon( - Icons.clear, - color: Theme.of(context).colorScheme.onSurfaceVariant, - ).paddedSymmetrically(horizontal: 16), - onPressed: () { - query = ''; - super.showSuggestions(context); - }, - ), - ]; - } -} diff --git a/lib/features/document_search/view/document_search_bar.dart b/lib/features/document_search/view/document_search_bar.dart deleted file mode 100644 index 2f70365..0000000 --- a/lib/features/document_search/view/document_search_bar.dart +++ /dev/null @@ -1,49 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:paperless_mobile/core/widgets/material/search/m3_search.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:provider/provider.dart'; - -class DocumentSearchBar extends StatelessWidget { - const DocumentSearchBar({ - super.key, - }); - - @override - Widget build(BuildContext context) { - return TextField( - onTap: () => showMaterial3Search( - context: context, - delegate: DocumentSearchDelegate( - DocumentSearchCubit(context.read()), - searchFieldStyle: Theme.of(context).textTheme.bodyLarge, - hintText: "Search documents", - ), - ), - readOnly: true, - decoration: InputDecoration( - hintText: "Search documents", - hintStyle: Theme.of(context) - .textTheme - .bodyLarge - ?.copyWith(color: Theme.of(context).colorScheme.onSurfaceVariant), - filled: true, - fillColor: Theme.of(context).colorScheme.surfaceVariant, - border: OutlineInputBorder( - borderRadius: BorderRadius.circular(56), - borderSide: BorderSide.none, - ), - prefixIcon: IconButton( - icon: const Icon(Icons.search), - onPressed: () { - Scaffold.of(context).openDrawer(); - }, - ), - constraints: const BoxConstraints(maxHeight: 48), - ), - // title: Text( - // "${S.of(context).documentsPageTitle} (${_formatDocumentCount(state.count)})", - // ), - ); - } -} diff --git a/lib/features/documents/bloc/documents_cubit.dart b/lib/features/documents/bloc/documents_cubit.dart index 1eb3e32..428fb91 100644 --- a/lib/features/documents/bloc/documents_cubit.dart +++ b/lib/features/documents/bloc/documents_cubit.dart @@ -5,10 +5,10 @@ import 'package:hydrated_bloc/hydrated_bloc.dart'; import 'package:paperless_api/paperless_api.dart'; import 'package:paperless_mobile/core/repository/saved_view_repository.dart'; import 'package:paperless_mobile/features/documents/bloc/documents_state.dart'; -import 'package:paperless_mobile/features/paged_document_view/documents_paging_mixin.dart'; +import 'package:paperless_mobile/features/paged_document_view/paged_documents_mixin.dart'; class DocumentsCubit extends HydratedCubit - with DocumentsPagingMixin { + with PagedDocumentsMixin { @override final PaperlessDocumentsApi api; diff --git a/lib/features/documents/bloc/documents_state.dart b/lib/features/documents/bloc/documents_state.dart index 2850d7c..23adc83 100644 --- a/lib/features/documents/bloc/documents_state.dart +++ b/lib/features/documents/bloc/documents_state.dart @@ -1,8 +1,8 @@ import 'package:json_annotation/json_annotation.dart'; import 'package:paperless_api/paperless_api.dart'; -import 'package:paperless_mobile/features/paged_document_view/model/documents_paged_state.dart'; +import 'package:paperless_mobile/features/paged_document_view/model/paged_documents_state.dart'; -class DocumentsState extends DocumentsPagedState { +class DocumentsState extends PagedDocumentsState { final int? selectedSavedViewId; @JsonKey(ignore: true) diff --git a/lib/features/documents/view/pages/documents_page.dart b/lib/features/documents/view/pages/documents_page.dart index a2a9f96..0777db5 100644 --- a/lib/features/documents/view/pages/documents_page.dart +++ b/lib/features/documents/view/pages/documents_page.dart @@ -5,8 +5,10 @@ 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/extensions/flutter_extensions.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'; @@ -25,6 +27,7 @@ import 'package:paperless_mobile/features/labels/bloc/providers/labels_bloc_prov 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/search/view/document_search_page.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'; @@ -32,7 +35,6 @@ 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'; -import 'package:paperless_mobile/constants.dart'; class DocumentFilterIntent { final DocumentFilter? filter; @@ -137,142 +139,151 @@ class _DocumentsPageState extends State { } }, builder: (context, connectivityState) { - const linearProgressIndicatorHeight = 4.0; return Scaffold( - drawer: BlocProvider.value( - value: context.read(), - child: AppDrawer( - afterInboxClosed: () => context.read().reload(), - ), - ), - appBar: PreferredSize( - preferredSize: const Size.fromHeight( - kToolbarHeight, - ), - child: BlocBuilder( - builder: (context, state) { - if (state.selection.isEmpty) { - return AppBar( - automaticallyImplyLeading: true, - 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", - ), - ); - }, - ), - const SortDocumentsButton(), - 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()); - }, - ), - ), - ], - ); - } 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, - ), - ); - }, - ), + // 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, + // ), + // ); + // }, + // ), resizeToAvoidBottomInset: true, - body: WillPopScope( - onWillPop: () async { - if (context.read().state.selection.isNotEmpty) { - context.read().resetSelection(); - } - return false; + 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"), + ), + ), + ) + ]; }, - child: RefreshIndicator( - onRefresh: _onRefresh, - notificationPredicate: (_) => connectivityState.isConnected, - child: BlocBuilder( - builder: (context, taskState) { - return Stack( - children: [ - _buildBody(connectivityState), - 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: 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, + // ), + // ); + // }, + // ), + // ), + // ], + // ); + }, + ), ), ), ), @@ -282,6 +293,28 @@ 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()); + }, + ), + ); + } + void _onDelete(BuildContext context, DocumentsState documentsState) async { final shouldDelete = await showDialog( context: context, @@ -392,7 +425,26 @@ class _DocumentsPageState extends State { onDocumentTypeSelected: _addDocumentTypeToFilter, onStoragePathSelected: _addStoragePathToFilter, pageLoadingWidget: const NewItemsLoadingWidget(), - beforeItems: const SizedBox(height: _savedViewWidgetHeight), + 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(), + ), + ), + ], + ), + ), ); }, ); diff --git a/lib/features/documents/view/widgets/documents_empty_state.dart b/lib/features/documents/view/widgets/documents_empty_state.dart index 0d5e6cd..9612fc7 100644 --- a/lib/features/documents/view/widgets/documents_empty_state.dart +++ b/lib/features/documents/view/widgets/documents_empty_state.dart @@ -2,11 +2,11 @@ import 'package:flutter/material.dart'; import 'package:paperless_api/paperless_api.dart'; import 'package:paperless_mobile/core/widgets/empty_state.dart'; import 'package:paperless_mobile/extensions/flutter_extensions.dart'; -import 'package:paperless_mobile/features/paged_document_view/model/documents_paged_state.dart'; +import 'package:paperless_mobile/features/paged_document_view/model/paged_documents_state.dart'; import 'package:paperless_mobile/generated/l10n.dart'; class DocumentsEmptyState extends StatelessWidget { - final DocumentsPagedState state; + final PagedDocumentsState state; final VoidCallback onReset; const DocumentsEmptyState({ Key? key, diff --git a/lib/features/documents/view/widgets/list/adaptive_documents_view.dart b/lib/features/documents/view/widgets/list/adaptive_documents_view.dart index 1f2b8d8..4e750db 100644 --- a/lib/features/documents/view/widgets/list/adaptive_documents_view.dart +++ b/lib/features/documents/view/widgets/list/adaptive_documents_view.dart @@ -9,7 +9,7 @@ import 'package:paperless_mobile/features/settings/model/view_type.dart'; class AdaptiveDocumentsView extends StatelessWidget { final DocumentsState state; final ViewType viewType; - final Widget beforeItems; + final Widget? beforeItems; final void Function(DocumentModel) onTap; final void Function(DocumentModel) onSelected; final ScrollController scrollController; @@ -34,7 +34,7 @@ class AdaptiveDocumentsView extends StatelessWidget { this.onDocumentTypeSelected, this.onStoragePathSelected, required this.pageLoadingWidget, - required this.beforeItems, + this.beforeItems, required this.viewType, }); diff --git a/lib/features/documents/view/widgets/sort_documents_button.dart b/lib/features/documents/view/widgets/sort_documents_button.dart index cccc276..a4610a8 100644 --- a/lib/features/documents/view/widgets/sort_documents_button.dart +++ b/lib/features/documents/view/widgets/sort_documents_button.dart @@ -4,51 +4,60 @@ import 'package:paperless_api/paperless_api.dart'; import 'package:paperless_mobile/core/repository/label_repository.dart'; import 'package:paperless_mobile/core/repository/state/impl/correspondent_repository_state.dart'; import 'package:paperless_mobile/core/repository/state/impl/document_type_repository_state.dart'; +import 'package:paperless_mobile/core/translation/sort_field_localization_mapper.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/search/sort_field_selection_bottom_sheet.dart'; import 'package:paperless_mobile/features/labels/bloc/label_cubit.dart'; class SortDocumentsButton extends StatelessWidget { - const SortDocumentsButton({super.key}); + const SortDocumentsButton({ + super.key, + }); @override Widget build(BuildContext context) { - return IconButton( - icon: const Icon(Icons.sort), - onPressed: () { - showModalBottomSheet( - elevation: 2, - context: context, - isScrollControlled: true, - shape: const RoundedRectangleBorder( - borderRadius: BorderRadius.only( - topLeft: Radius.circular(16), - topRight: Radius.circular(16), - ), - ), - builder: (_) => BlocProvider.value( - value: context.read(), - child: MultiBlocProvider( - providers: [ - BlocProvider( - create: (context) => LabelCubit( - context.read< - LabelRepository>(), - ), + return BlocBuilder( + builder: (context, state) { + if (state.filter.sortField == null) { + return const SizedBox.shrink(); + } + return TextButton.icon( + icon: Icon(state.filter.sortOrder == SortOrder.ascending + ? Icons.arrow_upward + : Icons.arrow_downward), + label: Text(translateSortField(context, state.filter.sortField)), + onPressed: () { + showModalBottomSheet( + elevation: 2, + context: context, + isScrollControlled: true, + shape: const RoundedRectangleBorder( + borderRadius: BorderRadius.only( + topLeft: Radius.circular(16), + topRight: Radius.circular(16), ), - BlocProvider( - create: (context) => LabelCubit( - context.read< - LabelRepository>(), - ), - ), - ], - child: BlocBuilder( - builder: (context, state) { - return SortFieldSelectionBottomSheet( + ), + builder: (_) => BlocProvider.value( + value: context.read(), + child: MultiBlocProvider( + providers: [ + BlocProvider( + create: (context) => LabelCubit( + context.read< + LabelRepository>(), + ), + ), + BlocProvider( + create: (context) => LabelCubit( + context.read< + LabelRepository>(), + ), + ), + ], + child: SortFieldSelectionBottomSheet( initialSortField: state.filter.sortField, initialSortOrder: state.filter.sortOrder, onSubmit: (field, order) => @@ -58,11 +67,11 @@ class SortDocumentsButton extends StatelessWidget { sortOrder: order, ), ), - ); - }, + ), + ), ), - ), - ), + ); + }, ); }, ); diff --git a/lib/features/home/view/home_page.dart b/lib/features/home/view/home_page.dart index d5baa79..b033504 100644 --- a/lib/features/home/view/home_page.dart +++ b/lib/features/home/view/home_page.dart @@ -249,7 +249,7 @@ class _HomePageState extends State { builder: (context, sizingInformation) { if (!sizingInformation.isMobile) { return Scaffold( - drawer: const AppDrawer(), + // drawer: const AppDrawer(), body: Row( children: [ NavigationRail( diff --git a/lib/features/home/view/widget/app_drawer.dart b/lib/features/home/view/widget/app_drawer.dart index f6b5c98..344a928 100644 --- a/lib/features/home/view/widget/app_drawer.dart +++ b/lib/features/home/view/widget/app_drawer.dart @@ -317,53 +317,5 @@ class _AppDrawerState extends State { ); } - Link _buildOnboardingImageCredits() { - return Link( - uri: Uri.parse( - 'https://www.freepik.com/free-vector/business-team-working-cogwheel-mechanism-together_8270974.htm#query=setting&position=4&from_view=author'), - builder: (context, followLink) => Wrap( - children: [ - const Text('Onboarding images by '), - GestureDetector( - onTap: followLink, - child: Text( - 'pch.vector', - style: TextStyle(color: Theme.of(context).colorScheme.tertiary), - ), - ), - const Text(' on Freepik.') - ], - ), - ); - } - - void _onShowAboutDialog() { - showAboutDialog( - context: context, - applicationIcon: const ImageIcon( - AssetImage('assets/logos/paperless_logo_green.png'), - ), - applicationName: 'Paperless Mobile', - applicationVersion: packageInfo.version + '+' + packageInfo.buildNumber, - children: [ - Text(S.of(context).aboutDialogDevelopedByText('Anton Stubenbord')), - Link( - uri: Uri.parse('https://github.com/astubenbord/paperless-mobile'), - builder: (context, followLink) => GestureDetector( - onTap: followLink, - child: Text( - 'https://github.com/astubenbord/paperless-mobile', - style: TextStyle(color: Theme.of(context).colorScheme.tertiary), - ), - ), - ), - const SizedBox(height: 16), - Text( - 'Credits', - style: Theme.of(context).textTheme.titleMedium, - ), - _buildOnboardingImageCredits(), - ], - ); - } + void _onShowAboutDialog() {} } diff --git a/lib/features/inbox/bloc/inbox_cubit.dart b/lib/features/inbox/bloc/inbox_cubit.dart index 22da765..2d2651a 100644 --- a/lib/features/inbox/bloc/inbox_cubit.dart +++ b/lib/features/inbox/bloc/inbox_cubit.dart @@ -7,9 +7,9 @@ import 'package:paperless_mobile/core/repository/state/impl/correspondent_reposi import 'package:paperless_mobile/core/repository/state/impl/document_type_repository_state.dart'; import 'package:paperless_mobile/core/repository/state/impl/tag_repository_state.dart'; import 'package:paperless_mobile/features/inbox/bloc/state/inbox_state.dart'; -import 'package:paperless_mobile/features/paged_document_view/documents_paging_mixin.dart'; +import 'package:paperless_mobile/features/paged_document_view/paged_documents_mixin.dart'; -class InboxCubit extends HydratedCubit with DocumentsPagingMixin { +class InboxCubit extends HydratedCubit with PagedDocumentsMixin { final LabelRepository _tagsRepository; final LabelRepository _correspondentRepository; diff --git a/lib/features/inbox/bloc/state/inbox_state.dart b/lib/features/inbox/bloc/state/inbox_state.dart index 186c501..be2aafc 100644 --- a/lib/features/inbox/bloc/state/inbox_state.dart +++ b/lib/features/inbox/bloc/state/inbox_state.dart @@ -1,13 +1,13 @@ import 'package:json_annotation/json_annotation.dart'; import 'package:paperless_api/paperless_api.dart'; -import 'package:paperless_mobile/features/paged_document_view/model/documents_paged_state.dart'; +import 'package:paperless_mobile/features/paged_document_view/model/paged_documents_state.dart'; part 'inbox_state.g.dart'; @JsonSerializable( ignoreUnannotated: true, ) -class InboxState extends DocumentsPagedState { +class InboxState extends PagedDocumentsState { final Iterable inboxTags; final Map availableTags; diff --git a/lib/features/inbox/view/pages/inbox_page.dart b/lib/features/inbox/view/pages/inbox_page.dart index 1f0245d..9028c2d 100644 --- a/lib/features/inbox/view/pages/inbox_page.dart +++ b/lib/features/inbox/view/pages/inbox_page.dart @@ -72,10 +72,10 @@ class _InboxPageState extends State { child: ColoredBox( color: Theme.of(context).colorScheme.secondaryContainer, child: Text( - state.value.isEmpty - ? '0' - : '${state.value.first.count} ' + - S.of(context).inboxPageUnseenText, + (state.value.isEmpty + ? '0 ' + : '${state.value.first.count} ') + + S.of(context).inboxPageUnseenText, textAlign: TextAlign.start, style: Theme.of(context).textTheme.bodySmall, ).paddedSymmetrically(horizontal: 4.0), diff --git a/lib/features/labels/view/pages/labels_page.dart b/lib/features/labels/view/pages/labels_page.dart index 0876dc7..f84bb3a 100644 --- a/lib/features/labels/view/pages/labels_page.dart +++ b/lib/features/labels/view/pages/labels_page.dart @@ -51,7 +51,6 @@ class _LabelsPageState extends State child: BlocBuilder( builder: (context, connectedState) { return Scaffold( - drawer: const AppDrawer(), appBar: AppBar( title: Text( [ diff --git a/lib/features/linked_documents/bloc/linked_documents_cubit.dart b/lib/features/linked_documents/bloc/linked_documents_cubit.dart index cf77aa6..ffc9343 100644 --- a/lib/features/linked_documents/bloc/linked_documents_cubit.dart +++ b/lib/features/linked_documents/bloc/linked_documents_cubit.dart @@ -1,25 +1,15 @@ import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:paperless_api/paperless_api.dart'; import 'package:paperless_mobile/features/linked_documents/bloc/state/linked_documents_state.dart'; +import 'package:paperless_mobile/features/paged_document_view/paged_documents_mixin.dart'; -class LinkedDocumentsCubit extends Cubit { - final PaperlessDocumentsApi _api; +class LinkedDocumentsCubit extends Cubit + with PagedDocumentsMixin { + @override + final PaperlessDocumentsApi api; - LinkedDocumentsCubit(this._api, DocumentFilter filter) - : super(LinkedDocumentsState(filter: filter)) { - _initialize(); - } - - Future _initialize() async { - final documents = await _api.findAll( - state.filter.copyWith( - pageSize: 100, - ), - ); - emit(LinkedDocumentsState( - isLoaded: true, - documents: documents, - filter: state.filter, - )); + LinkedDocumentsCubit(this.api, DocumentFilter filter) + : super(const LinkedDocumentsState()) { + updateFilter(filter: filter); } } diff --git a/lib/features/linked_documents/bloc/state/linked_documents_state.dart b/lib/features/linked_documents/bloc/state/linked_documents_state.dart index abb2f4b..d72a3e5 100644 --- a/lib/features/linked_documents/bloc/state/linked_documents_state.dart +++ b/lib/features/linked_documents/bloc/state/linked_documents_state.dart @@ -1,13 +1,48 @@ import 'package:paperless_api/paperless_api.dart'; +import 'package:paperless_mobile/features/paged_document_view/model/paged_documents_state.dart'; -class LinkedDocumentsState { - final bool isLoaded; - final PagedSearchResult? documents; - final DocumentFilter filter; - - LinkedDocumentsState({ - required this.filter, - this.isLoaded = false, - this.documents, +class LinkedDocumentsState extends PagedDocumentsState { + const LinkedDocumentsState({ + super.filter, + super.isLoading, + super.hasLoaded, + super.value, }); + + LinkedDocumentsState copyWith({ + DocumentFilter? filter, + bool? isLoading, + bool? hasLoaded, + List>? value, + }) { + return LinkedDocumentsState( + filter: filter ?? this.filter, + isLoading: isLoading ?? this.isLoading, + hasLoaded: hasLoaded ?? this.hasLoaded, + value: value ?? this.value, + ); + } + + @override + LinkedDocumentsState copyWithPaged({ + bool? hasLoaded, + bool? isLoading, + List>? value, + DocumentFilter? filter, + }) { + return copyWith( + hasLoaded: hasLoaded, + isLoading: isLoading, + value: value, + filter: filter, + ); + } + + @override + List get props => [ + filter, + isLoading, + hasLoaded, + value, + ]; } 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 5cce90f..b741104 100644 --- a/lib/features/linked_documents/view/pages/linked_documents_page.dart +++ b/lib/features/linked_documents/view/pages/linked_documents_page.dart @@ -8,6 +8,7 @@ import 'package:paperless_mobile/features/documents/view/widgets/list/document_l 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'; +import 'package:paperless_mobile/helpers/message_helpers.dart'; class LinkedDocumentsPage extends StatefulWidget { const LinkedDocumentsPage({super.key}); @@ -17,6 +18,28 @@ class LinkedDocumentsPage extends StatefulWidget { } class _LinkedDocumentsPageState extends State { + final _scrollController = ScrollController(); + + @override + void initState() { + super.initState(); + _scrollController.addListener(_listenForLoadNewData); + } + + 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); + } + } + } + @override Widget build(BuildContext context) { return Scaffold( @@ -25,45 +48,14 @@ class _LinkedDocumentsPageState extends State { ), body: BlocBuilder( builder: (context, state) { - return Column( - children: [ - Text( - S.of(context).referencedDocumentsReadOnlyHintText, - textAlign: TextAlign.center, - style: Theme.of(context).textTheme.bodySmall, - ), - if (!state.isLoaded) - const Expanded(child: DocumentsListLoadingWidget()) - else - Expanded( - child: ListView.builder( - itemCount: state.documents?.results.length, - itemBuilder: (context, index) { - return DocumentListItem( - isLabelClickable: false, - document: state.documents!.results.elementAt(index), - onTap: (doc) { - Navigator.push( - context, - MaterialPageRoute( - builder: (context) => BlocProvider( - create: (context) => DocumentDetailsCubit( - context.read(), - state.documents!.results.elementAt(index), - ), - child: const DocumentDetailsPage( - isLabelClickable: false, - allowEdit: false, - ), - ), - ), - ); - }, - ); - }, - ), - ), - ], + if (!state.hasLoaded) { + return const DocumentsListLoadingWidget(); + } + return ListView.builder( + itemCount: state.documents.length, + itemBuilder: (context, index) => DocumentListItem( + document: state.documents[index], + ), ); }, ), diff --git a/lib/features/paged_document_view/model/documents_paged_state.dart b/lib/features/paged_document_view/model/paged_documents_state.dart similarity index 88% rename from lib/features/paged_document_view/model/documents_paged_state.dart rename to lib/features/paged_document_view/model/paged_documents_state.dart index 71df68b..e50fe46 100644 --- a/lib/features/paged_document_view/model/documents_paged_state.dart +++ b/lib/features/paged_document_view/model/paged_documents_state.dart @@ -5,13 +5,13 @@ import 'package:paperless_api/paperless_api.dart'; /// Base state for all blocs/cubits using a paged view of documents. /// [T] is the return type of the API call. /// -abstract class DocumentsPagedState extends Equatable { +abstract class PagedDocumentsState extends Equatable { final bool hasLoaded; final bool isLoading; final List> value; final DocumentFilter filter; - const DocumentsPagedState({ + const PagedDocumentsState({ this.value = const [], this.hasLoaded = false, this.isLoading = false, @@ -71,4 +71,12 @@ abstract class DocumentsPagedState extends Equatable { List>? value, DocumentFilter? filter, }); + + @override + List get props => [ + filter, + value, + hasLoaded, + isLoading, + ]; } diff --git a/lib/features/paged_document_view/documents_paging_mixin.dart b/lib/features/paged_document_view/paged_documents_mixin.dart similarity index 97% rename from lib/features/paged_document_view/documents_paging_mixin.dart rename to lib/features/paged_document_view/paged_documents_mixin.dart index d012c9b..687d410 100644 --- a/lib/features/paged_document_view/documents_paging_mixin.dart +++ b/lib/features/paged_document_view/paged_documents_mixin.dart @@ -2,12 +2,12 @@ import 'package:collection/collection.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:paperless_api/paperless_api.dart'; -import 'model/documents_paged_state.dart'; +import 'model/paged_documents_state.dart'; /// /// Mixin which can be used on cubits which handle documents. This implements all paging and filtering logic. /// -mixin DocumentsPagingMixin +mixin PagedDocumentsMixin on BlocBase { PaperlessDocumentsApi get api; diff --git a/lib/features/scan/view/scanner_page.dart b/lib/features/scan/view/scanner_page.dart index 37f2776..bfa8fc4 100644 --- a/lib/features/scan/view/scanner_page.dart +++ b/lib/features/scan/view/scanner_page.dart @@ -47,7 +47,6 @@ class _ScannerPageState extends State return BlocBuilder( builder: (context, connectedState) { return Scaffold( - drawer: const AppDrawer(), floatingActionButton: FloatingActionButton( onPressed: () => _openDocumentScanner(context), child: const Icon(Icons.add_a_photo_outlined), diff --git a/lib/features/search/cubit/document_search_cubit.dart b/lib/features/search/cubit/document_search_cubit.dart new file mode 100644 index 0000000..390c68b --- /dev/null +++ b/lib/features/search/cubit/document_search_cubit.dart @@ -0,0 +1,68 @@ +import 'package:collection/collection.dart'; +import 'package:hydrated_bloc/hydrated_bloc.dart'; +import 'package:paperless_api/paperless_api.dart'; +import 'package:paperless_mobile/features/paged_document_view/paged_documents_mixin.dart'; +import 'package:paperless_mobile/features/search/cubit/document_search_state.dart'; + +class DocumentSearchCubit extends HydratedCubit + with PagedDocumentsMixin { + @override + final PaperlessDocumentsApi api; + DocumentSearchCubit(this.api) : super(const DocumentSearchState()); + + Future search(String query) async { + emit(state.copyWith( + isLoading: true, + suggestions: [], + view: SearchView.results, + )); + final searchFilter = DocumentFilter( + query: TextQuery.titleAndContent(query), + ); + + await updateFilter(filter: searchFilter); + emit( + state.copyWith( + searchHistory: [ + query, + ...state.searchHistory + .whereNot((previousQuery) => previousQuery == query) + ], + ), + ); + } + + Future suggest(String query) async { + emit( + state.copyWith( + isLoading: true, + view: SearchView.suggestions, + value: [], + suggestions: [], + ), + ); + final suggestions = await api.autocomplete(query); + emit(state.copyWith( + suggestions: suggestions, + isLoading: false, + )); + } + + void reset() { + emit(state.copyWith( + view: SearchView.suggestions, + suggestions: [], + isLoading: false, + )); + } + + @override + DocumentSearchState? fromJson(Map json) { + return DocumentSearchState.fromJson(json); + } + + @override + Map? toJson(DocumentSearchState state) { + return state.toJson(); + } +} diff --git a/lib/features/document_search/cubit/document_search_state.dart b/lib/features/search/cubit/document_search_state.dart similarity index 79% rename from lib/features/document_search/cubit/document_search_state.dart rename to lib/features/search/cubit/document_search_state.dart index 4286fb5..6667d7f 100644 --- a/lib/features/document_search/cubit/document_search_state.dart +++ b/lib/features/search/cubit/document_search_state.dart @@ -1,17 +1,25 @@ import 'package:equatable/equatable.dart'; import 'package:json_annotation/json_annotation.dart'; import 'package:paperless_api/paperless_api.dart'; -import 'package:paperless_mobile/features/paged_document_view/model/documents_paged_state.dart'; +import 'package:paperless_mobile/features/paged_document_view/model/paged_documents_state.dart'; part 'document_search_state.g.dart'; +enum SearchView { + suggestions, + results; +} + @JsonSerializable(ignoreUnannotated: true) -class DocumentSearchState extends DocumentsPagedState { +class DocumentSearchState extends PagedDocumentsState { @JsonKey() final List searchHistory; - + final SearchView view; + final List suggestions; const DocumentSearchState({ + this.view = SearchView.suggestions, this.searchHistory = const [], + this.suggestions = const [], super.filter, super.hasLoaded, super.isLoading, @@ -25,6 +33,8 @@ class DocumentSearchState extends DocumentsPagedState { filter, value, searchHistory, + suggestions, + view, ]; @override @@ -49,6 +59,7 @@ class DocumentSearchState extends DocumentsPagedState { List>? value, DocumentFilter? filter, List? suggestions, + SearchView? view, }) { return DocumentSearchState( value: value ?? this.value, @@ -56,6 +67,8 @@ class DocumentSearchState extends DocumentsPagedState { hasLoaded: hasLoaded ?? this.hasLoaded, isLoading: isLoading ?? this.isLoading, searchHistory: searchHistory ?? this.searchHistory, + view: view ?? this.view, + suggestions: suggestions ?? this.suggestions, ); } diff --git a/lib/features/document_search/cubit/document_search_state.g.dart b/lib/features/search/cubit/document_search_state.g.dart similarity index 100% rename from lib/features/document_search/cubit/document_search_state.g.dart rename to lib/features/search/cubit/document_search_state.g.dart diff --git a/lib/features/search/view/document_search_page.dart b/lib/features/search/view/document_search_page.dart new file mode 100644 index 0000000..2bedd3d --- /dev/null +++ b/lib/features/search/view/document_search_page.dart @@ -0,0 +1,166 @@ +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/search/cubit/document_search_cubit.dart'; +import 'package:paperless_mobile/generated/l10n.dart'; + +Future showDocumentSearchPage(BuildContext context) { + return Navigator.of(context).push( + MaterialPageRoute( + builder: (context) => BlocProvider( + create: (context) => DocumentSearchCubit(context.read()), + child: const DocumentSearchPage(), + ), + ), + ); +} + +class DocumentSearchPage extends StatefulWidget { + const DocumentSearchPage({super.key}); + + @override + State createState() => _DocumentSearchPageState(); +} + +class _DocumentSearchPageState extends State { + final _queryController = TextEditingController(text: ''); + + String get query => _queryController.text; + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + return Scaffold( + appBar: AppBar( + backgroundColor: theme.colorScheme.surface, + toolbarHeight: 72, + leading: BackButton( + color: theme.colorScheme.onSurface, + ), + title: TextField( + autofocus: true, + style: theme.textTheme.bodyLarge?.apply( + color: theme.colorScheme.onSurface, + ), + decoration: InputDecoration( + hintStyle: theme.textTheme.bodyLarge?.apply( + color: theme.colorScheme.onSurfaceVariant, + ), + hintText: "Search documents", + border: InputBorder.none, + ), + controller: _queryController, + onChanged: context.read().suggest, + onSubmitted: context.read().search, + ), + actions: [ + IconButton( + color: theme.colorScheme.onSurfaceVariant, + icon: Icon(Icons.clear), + onPressed: () { + context.read().reset(); + _queryController.clear(); + }, + ) + ], + bottom: PreferredSize( + preferredSize: Size.fromHeight(1), + child: Divider( + color: theme.colorScheme.outline, + ), + ), + ), + body: BlocBuilder( + builder: (context, state) { + switch (state.view) { + case SearchView.suggestions: + return _buildSuggestionsView(state); + case SearchView.results: + return _buildResultsView(state); + } + }, + ), + ); + } + + Widget _buildSuggestionsView(DocumentSearchState state) { + final suggestions = state.suggestions + .whereNot((element) => state.searchHistory.contains(element)) + .toList(); + final historyMatches = state.searchHistory + .where( + (element) => element.startsWith(query), + ) + .toList(); + return CustomScrollView( + slivers: [ + SliverList( + delegate: SliverChildBuilderDelegate( + (context, index) => ListTile( + title: Text(historyMatches[index]), + leading: Icon(Icons.history), + onTap: () => _selectSuggestion(historyMatches[index]), + ), + childCount: historyMatches.length, + ), + ), + if (state.isLoading) + const SliverToBoxAdapter( + child: Center( + child: CircularProgressIndicator(), + ), + ) + else + SliverList( + delegate: SliverChildBuilderDelegate( + (context, index) => ListTile( + title: Text(suggestions[index]), + leading: Icon(Icons.search), + onTap: () => _selectSuggestion(suggestions[index]), + ), + childCount: suggestions.length, + ), + ) + ], + ); + } + + Widget _buildResultsView(DocumentSearchState state) { + final header = Text( + 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.")), + ) + else + SliverList( + delegate: SliverChildBuilderDelegate( + (context, index) => DocumentListItem( + document: state.documents[index], + ), + childCount: state.documents.length, + ), + ), + ], + ); + } + + void _selectSuggestion(String suggestion) { + context.read().search(suggestion); + } +} diff --git a/lib/features/settings/bloc/application_settings_state.dart b/lib/features/settings/bloc/application_settings_state.dart index 7dfc48f..5771bdd 100644 --- a/lib/features/settings/bloc/application_settings_state.dart +++ b/lib/features/settings/bloc/application_settings_state.dart @@ -4,6 +4,7 @@ import 'package:flutter/material.dart'; import 'package:json_annotation/json_annotation.dart'; import 'package:paperless_mobile/features/settings/model/color_scheme_option.dart'; import 'package:paperless_mobile/features/settings/model/view_type.dart'; +import 'package:paperless_mobile/generated/l10n.dart'; part 'application_settings_state.g.dart'; @@ -13,7 +14,7 @@ part 'application_settings_state.g.dart'; @JsonSerializable() class ApplicationSettingsState { static final defaultSettings = ApplicationSettingsState( - preferredLocaleSubtag: Platform.localeName.split('_').first, + preferredLocaleSubtag: _defaultPreferredLocaleSubtag, ); final bool isLocalAuthenticationEnabled; @@ -52,4 +53,13 @@ class ApplicationSettingsState { preferredColorSchemeOption ?? this.preferredColorSchemeOption, ); } + + static String get _defaultPreferredLocaleSubtag { + String preferredLocale = Platform.localeName.split("_").first; + if (!S.delegate.supportedLocales + .any((locale) => locale.languageCode == preferredLocale)) { + preferredLocale = 'en'; + } + return preferredLocale; + } } diff --git a/lib/features/settings/view/settings_page.dart b/lib/features/settings/view/settings_page.dart index 89db6ad..f29a4db 100644 --- a/lib/features/settings/view/settings_page.dart +++ b/lib/features/settings/view/settings_page.dart @@ -1,10 +1,22 @@ import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:hydrated_bloc/hydrated_bloc.dart'; +import 'package:paperless_api/paperless_api.dart'; +import 'package:paperless_mobile/core/bloc/paperless_server_information_cubit.dart'; +import 'package:paperless_mobile/core/bloc/paperless_server_information_state.dart'; +import 'package:paperless_mobile/core/repository/label_repository.dart'; +import 'package:paperless_mobile/core/repository/saved_view_repository.dart'; +import 'package:paperless_mobile/core/repository/state/impl/correspondent_repository_state.dart'; +import 'package:paperless_mobile/core/repository/state/impl/document_type_repository_state.dart'; +import 'package:paperless_mobile/core/repository/state/impl/storage_path_repository_state.dart'; +import 'package:paperless_mobile/core/repository/state/impl/tag_repository_state.dart'; +import 'package:paperless_mobile/features/login/bloc/authentication_cubit.dart'; import 'package:paperless_mobile/features/settings/bloc/application_settings_cubit.dart'; import 'package:paperless_mobile/features/settings/view/pages/application_settings_page.dart'; import 'package:paperless_mobile/features/settings/view/pages/security_settings_page.dart'; import 'package:paperless_mobile/features/settings/view/pages/storage_settings_page.dart'; import 'package:paperless_mobile/generated/l10n.dart'; +import 'package:paperless_mobile/helpers/message_helpers.dart'; class SettingsPage extends StatelessWidget { const SettingsPage({super.key}); @@ -14,22 +26,58 @@ class SettingsPage extends StatelessWidget { return Scaffold( appBar: AppBar( title: Text(S.of(context).appDrawerSettingsLabel), + actions: [ + IconButton( + icon: const Icon(Icons.logout), + color: Theme.of(context).colorScheme.error, + onPressed: () async { + await _onLogout(context); + Navigator.pop(context); + }, + ), + ], + ), + bottomNavigationBar: BlocBuilder( + builder: (context, state) { + final info = state.information!; + + return ListTile( + title: Text( + S.of(context).appDrawerHeaderLoggedInAsText + + " " + + (info.username ?? 'unknown') + + "@${info.host}", + style: Theme.of(context).textTheme.bodySmall, + ), + subtitle: Text( + S.of(context).serverInformationPaperlessVersionText + + ' ' + + info.version.toString() + + ' (API v${info.apiVersion})', + style: Theme.of(context).textTheme.bodySmall, + ), + ); + }, ), body: ListView( children: [ ListTile( + // leading: const Icon(Icons.style_outlined), title: Text(S.of(context).settingsPageApplicationSettingsLabel), subtitle: Text( S.of(context).settingsPageApplicationSettingsDescriptionText), onTap: () => _goto(const ApplicationSettingsPage(), context), ), ListTile( + // leading: const Icon(Icons.security_outlined), title: Text(S.of(context).settingsPageSecuritySettingsLabel), subtitle: Text(S.of(context).settingsPageSecuritySettingsDescriptionText), onTap: () => _goto(const SecuritySettingsPage(), context), ), ListTile( + // leading: const Icon(Icons.storage_outlined), title: Text(S.of(context).settingsPageStorageSettingsLabel), subtitle: Text(S.of(context).settingsPageStorageSettingsDescriptionText), @@ -52,4 +100,25 @@ class SettingsPage extends StatelessWidget { ), ); } + + Future _onLogout(BuildContext context) async { + try { + await context.read().logout(); + await context.read().clear(); + await context.read>().clear(); + await context + .read>() + .clear(); + await context + .read>() + .clear(); + await context + .read>() + .clear(); + await context.read().clear(); + await HydratedBloc.storage.clear(); + } on PaperlessServerException catch (error, stackTrace) { + showErrorMessage(context, error, stackTrace); + } + } } diff --git a/lib/features/settings/view/widgets/language_selection_setting.dart b/lib/features/settings/view/widgets/language_selection_setting.dart index 38470e4..b141612 100644 --- a/lib/features/settings/view/widgets/language_selection_setting.dart +++ b/lib/features/settings/view/widgets/language_selection_setting.dart @@ -1,5 +1,6 @@ import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:paperless_mobile/core/widgets/hint_card.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/view/widgets/radio_settings_dialog.dart'; @@ -20,6 +21,7 @@ class _LanguageSelectionSettingState extends State { 'cs': 'Česky', 'tr': 'Türkçe', }; + @override Widget build(BuildContext context) { return BlocBuilder( @@ -27,9 +29,12 @@ class _LanguageSelectionSettingState extends State { return ListTile( title: Text(S.of(context).settingsPageLanguageSettingLabel), subtitle: Text(_languageOptions[settings.preferredLocaleSubtag]!), - onTap: () => showDialog( + onTap: () => showDialog( context: context, builder: (_) => RadioSettingsDialog( + footer: const Text( + "* Work in progress, not fully translated yet. Some words may be displayed in English!", + ), titleText: S.of(context).settingsPageLanguageSettingLabel, options: [ RadioOption( @@ -42,11 +47,11 @@ class _LanguageSelectionSettingState extends State { ), RadioOption( value: 'cs', - label: _languageOptions['cs']!, + label: _languageOptions['cs']! + " *", ), RadioOption( value: 'tr', - label: _languageOptions['tr']!, + label: _languageOptions['tr']! + " *", ) ], initialValue: context diff --git a/lib/features/similar_documents/cubit/similar_documents_cubit.dart b/lib/features/similar_documents/cubit/similar_documents_cubit.dart index 13dd481..dbaee29 100644 --- a/lib/features/similar_documents/cubit/similar_documents_cubit.dart +++ b/lib/features/similar_documents/cubit/similar_documents_cubit.dart @@ -1,12 +1,12 @@ import 'package:bloc/bloc.dart'; import 'package:paperless_api/paperless_api.dart'; -import 'package:paperless_mobile/features/paged_document_view/documents_paging_mixin.dart'; -import 'package:paperless_mobile/features/paged_document_view/model/documents_paged_state.dart'; +import 'package:paperless_mobile/features/paged_document_view/paged_documents_mixin.dart'; +import 'package:paperless_mobile/features/paged_document_view/model/paged_documents_state.dart'; part 'similar_documents_state.dart'; class SimilarDocumentsCubit extends Cubit - with DocumentsPagingMixin { + with PagedDocumentsMixin { final int documentId; @override diff --git a/lib/features/similar_documents/cubit/similar_documents_state.dart b/lib/features/similar_documents/cubit/similar_documents_state.dart index 4c4c664..75b683e 100644 --- a/lib/features/similar_documents/cubit/similar_documents_state.dart +++ b/lib/features/similar_documents/cubit/similar_documents_state.dart @@ -1,6 +1,6 @@ part of 'similar_documents_cubit.dart'; -class SimilarDocumentsState extends DocumentsPagedState { +class SimilarDocumentsState extends PagedDocumentsState { const SimilarDocumentsState({ super.filter, super.hasLoaded, diff --git a/lib/helpers/format_helpers.dart b/lib/helpers/format_helpers.dart index 5b863c4..d93ca57 100644 --- a/lib/helpers/format_helpers.dart +++ b/lib/helpers/format_helpers.dart @@ -4,7 +4,7 @@ String formatMaxCount(int? count, [int maxCount = 99]) { if ((count ?? 0) > maxCount) { return "$maxCount+"; } - return (count ?? 0).toString().padLeft(maxCount.toString().length); + return (count ?? 0).toString(); } String formatBytes(int bytes, int decimals) {