From e68e3af713c10217f408f33df5ee30bfb95c6398 Mon Sep 17 00:00:00 2001 From: Anton Stubenbord Date: Tue, 24 Jan 2023 00:38:37 +0100 Subject: [PATCH] WIP - Added document search, restructured navigation --- .../widgets/material/search/m3_search.dart | 601 ++++++++++++++++++ .../material/search/m3_search_bar.dart | 81 +++ .../cubit/document_search_cubit.dart | 32 +- .../cubit/document_search_state.dart | 9 - .../cubit/document_search_state.g.dart | 21 + .../document_search_delegate.dart | 188 ++++-- .../view/document_search_app_bar.dart | 49 ++ .../documents/view/pages/documents_page.dart | 51 +- lib/features/home/view/home_page.dart | 46 +- lib/features/inbox/view/pages/inbox_page.dart | 1 + lib/l10n/intl_cs.arb | 3 +- lib/l10n/intl_de.arb | 3 +- lib/l10n/intl_en.arb | 3 +- lib/l10n/intl_tr.arb | 3 +- lib/theme.dart | 5 +- 15 files changed, 970 insertions(+), 126 deletions(-) create mode 100644 lib/core/widgets/material/search/m3_search.dart create mode 100644 lib/core/widgets/material/search/m3_search_bar.dart create mode 100644 lib/features/document_search/cubit/document_search_state.g.dart create mode 100644 lib/features/document_search/view/document_search_app_bar.dart diff --git a/lib/core/widgets/material/search/m3_search.dart b/lib/core/widgets/material/search/m3_search.dart new file mode 100644 index 0000000..4be3600 --- /dev/null +++ b/lib/core/widgets/material/search/m3_search.dart @@ -0,0 +1,601 @@ +// Copyright 2014 The Flutter Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter/widgets.dart'; + +/// Shows a full screen search page and returns the search result selected by +/// the user when the page is closed. +/// +/// The search page consists of an app bar with a search field and a body which +/// can either show suggested search queries or the search results. +/// +/// The appearance of the search page is determined by the provided +/// `delegate`. The initial query string is given by `query`, which defaults +/// to the empty string. When `query` is set to null, `delegate.query` will +/// be used as the initial query. +/// +/// This method returns the selected search result, which can be set in the +/// [SearchDelegate.close] call. If the search page is closed with the system +/// back button, it returns null. +/// +/// A given [SearchDelegate] can only be associated with one active [showMaterial3Search] +/// call. Call [SearchDelegate.close] before re-using the same delegate instance +/// for another [showMaterial3Search] call. +/// +/// The `useRootNavigator` argument is used to determine whether to push the +/// search page to the [Navigator] furthest from or nearest to the given +/// `context`. By default, `useRootNavigator` is `false` and the search page +/// route created by this method is pushed to the nearest navigator to the +/// given `context`. It can not be `null`. +/// +/// The transition to the search page triggered by this method looks best if the +/// screen triggering the transition contains an [AppBar] at the top and the +/// transition is called from an [IconButton] that's part of [AppBar.actions]. +/// The animation provided by [SearchDelegate.transitionAnimation] can be used +/// to trigger additional animations in the underlying page while the search +/// page fades in or out. This is commonly used to animate an [AnimatedIcon] in +/// the [AppBar.leading] position e.g. from the hamburger menu to the back arrow +/// used to exit the search page. +/// +/// ## Handling emojis and other complex characters +/// {@macro flutter.widgets.EditableText.onChanged} +/// +/// See also: +/// +/// * [SearchDelegate] to define the content of the search page. +Future showMaterial3Search({ + required BuildContext context, + required SearchDelegate delegate, + String? query = '', + bool useRootNavigator = false, +}) { + delegate.query = query ?? delegate.query; + delegate._currentBody = _SearchBody.suggestions; + return Navigator.of(context, rootNavigator: useRootNavigator) + .push(_SearchPageRoute( + delegate: delegate, + )); +} + +/// Delegate for [showMaterial3Search] to define the content of the search page. +/// +/// The search page always shows an [AppBar] at the top where users can +/// enter their search queries. The buttons shown before and after the search +/// query text field can be customized via [SearchDelegate.buildLeading] +/// and [SearchDelegate.buildActions]. Additionally, a widget can be placed +/// across the bottom of the [AppBar] via [SearchDelegate.buildBottom]. +/// +/// The body below the [AppBar] can either show suggested queries (returned by +/// [SearchDelegate.buildSuggestions]) or - once the user submits a search - the +/// results of the search as returned by [SearchDelegate.buildResults]. +/// +/// [SearchDelegate.query] always contains the current query entered by the user +/// and should be used to build the suggestions and results. +/// +/// The results can be brought on screen by calling [SearchDelegate.showResults] +/// and you can go back to showing the suggestions by calling +/// [SearchDelegate.showSuggestions]. +/// +/// Once the user has selected a search result, [SearchDelegate.close] should be +/// called to remove the search page from the top of the navigation stack and +/// to notify the caller of [showMaterial3Search] about the selected search result. +/// +/// A given [SearchDelegate] can only be associated with one active [showMaterial3Search] +/// call. Call [SearchDelegate.close] before re-using the same delegate instance +/// for another [showMaterial3Search] call. +/// +/// ## Handling emojis and other complex characters +/// {@macro flutter.widgets.EditableText.onChanged} +abstract class SearchDelegate { + /// Constructor to be called by subclasses which may specify + /// [searchFieldLabel], either [searchFieldStyle] or [searchFieldDecorationTheme], + /// [keyboardType] and/or [textInputAction]. Only one of [searchFieldLabel] + /// and [searchFieldDecorationTheme] may be non-null. + /// + /// {@tool snippet} + /// ```dart + /// class CustomSearchHintDelegate extends SearchDelegate { + /// CustomSearchHintDelegate({ + /// required String hintText, + /// }) : super( + /// searchFieldLabel: hintText, + /// keyboardType: TextInputType.text, + /// textInputAction: TextInputAction.search, + /// ); + /// + /// @override + /// Widget buildLeading(BuildContext context) => const Text('leading'); + /// + /// @override + /// PreferredSizeWidget buildBottom(BuildContext context) { + /// return const PreferredSize( + /// preferredSize: Size.fromHeight(56.0), + /// child: Text('bottom')); + /// } + /// + /// @override + /// Widget buildSuggestions(BuildContext context) => const Text('suggestions'); + /// + /// @override + /// Widget buildResults(BuildContext context) => const Text('results'); + /// + /// @override + /// List buildActions(BuildContext context) => []; + /// } + /// ``` + /// {@end-tool} + SearchDelegate({ + this.searchFieldLabel, + this.searchFieldStyle, + this.searchFieldDecorationTheme, + this.keyboardType, + this.textInputAction = TextInputAction.search, + }) : assert(searchFieldStyle == null || searchFieldDecorationTheme == null); + + /// Suggestions shown in the body of the search page while the user types a + /// query into the search field. + /// + /// The delegate method is called whenever the content of [query] changes. + /// The suggestions should be based on the current [query] string. If the query + /// string is empty, it is good practice to show suggested queries based on + /// past queries or the current context. + /// + /// Usually, this method will return a [ListView] with one [ListTile] per + /// suggestion. When [ListTile.onTap] is called, [query] should be updated + /// with the corresponding suggestion and the results page should be shown + /// by calling [showResults]. + Widget buildSuggestions(BuildContext context); + + /// The results shown after the user submits a search from the search page. + /// + /// The current value of [query] can be used to determine what the user + /// searched for. + /// + /// This method might be applied more than once to the same query. + /// If your [buildResults] method is computationally expensive, you may want + /// to cache the search results for one or more queries. + /// + /// Typically, this method returns a [ListView] with the search results. + /// When the user taps on a particular search result, [close] should be called + /// with the selected result as argument. This will close the search page and + /// communicate the result back to the initial caller of [showMaterial3Search]. + Widget buildResults(BuildContext context); + + /// A widget to display before the current query in the [AppBar]. + /// + /// Typically an [IconButton] configured with a [BackButtonIcon] that exits + /// the search with [close]. One can also use an [AnimatedIcon] driven by + /// [transitionAnimation], which animates from e.g. a hamburger menu to the + /// back button as the search overlay fades in. + /// + /// Returns null if no widget should be shown. + /// + /// See also: + /// + /// * [AppBar.leading], the intended use for the return value of this method. + Widget? buildLeading(BuildContext context); + + /// Widgets to display after the search query in the [AppBar]. + /// + /// If the [query] is not empty, this should typically contain a button to + /// clear the query and show the suggestions again (via [showSuggestions]) if + /// the results are currently shown. + /// + /// Returns null if no widget should be shown. + /// + /// See also: + /// + /// * [AppBar.actions], the intended use for the return value of this method. + List? buildActions(BuildContext context); + + /// Widget to display across the bottom of the [AppBar]. + /// + /// Returns null by default, i.e. a bottom widget is not included. + /// + /// See also: + /// + /// * [AppBar.bottom], the intended use for the return value of this method. + /// + PreferredSizeWidget? buildBottom(BuildContext context) => null; + + /// The theme used to configure the search page. + /// + /// The returned [ThemeData] will be used to wrap the entire search page, + /// so it can be used to configure any of its components with the appropriate + /// theme properties. + /// + /// Unless overridden, the default theme will configure the AppBar containing + /// the search input text field with a white background and black text on light + /// themes. For dark themes the default is a dark grey background with light + /// color text. + /// + /// See also: + /// + /// * [AppBarTheme], which configures the AppBar's appearance. + /// * [InputDecorationTheme], which configures the appearance of the search + /// text field. + ThemeData appBarTheme(BuildContext context) { + final ThemeData theme = Theme.of(context); + final ColorScheme colorScheme = theme.colorScheme; + return theme.copyWith( + appBarTheme: AppBarTheme( + brightness: colorScheme.brightness, + backgroundColor: colorScheme.brightness == Brightness.dark + ? Colors.grey[900] + : Colors.white, + iconTheme: theme.primaryIconTheme.copyWith(color: Colors.grey), + textTheme: theme.textTheme, + ), + inputDecorationTheme: searchFieldDecorationTheme ?? + InputDecorationTheme( + hintStyle: searchFieldStyle ?? theme.inputDecorationTheme.hintStyle, + border: InputBorder.none, + ), + ); + } + + /// The current query string shown in the [AppBar]. + /// + /// The user manipulates this string via the keyboard. + /// + /// If the user taps on a suggestion provided by [buildSuggestions] this + /// string should be updated to that suggestion via the setter. + String get query => _queryTextController.text; + + /// Changes the current query string. + /// + /// Setting the query string programmatically moves the cursor to the end of the text field. + set query(String value) { + assert(query != null); + _queryTextController.text = value; + if (_queryTextController.text.isNotEmpty) { + _queryTextController.selection = TextSelection.fromPosition( + TextPosition(offset: _queryTextController.text.length)); + } + } + + /// Transition from the suggestions returned by [buildSuggestions] to the + /// [query] results returned by [buildResults]. + /// + /// If the user taps on a suggestion provided by [buildSuggestions] the + /// screen should typically transition to the page showing the search + /// results for the suggested query. This transition can be triggered + /// by calling this method. + /// + /// See also: + /// + /// * [showSuggestions] to show the search suggestions again. + void showResults(BuildContext context) { + _focusNode?.unfocus(); + _currentBody = _SearchBody.results; + } + + /// Transition from showing the results returned by [buildResults] to showing + /// the suggestions returned by [buildSuggestions]. + /// + /// Calling this method will also put the input focus back into the search + /// field of the [AppBar]. + /// + /// If the results are currently shown this method can be used to go back + /// to showing the search suggestions. + /// + /// See also: + /// + /// * [showResults] to show the search results. + void showSuggestions(BuildContext context) { + assert(_focusNode != null, + '_focusNode must be set by route before showSuggestions is called.'); + _focusNode!.requestFocus(); + _currentBody = _SearchBody.suggestions; + } + + /// Closes the search page and returns to the underlying route. + /// + /// The value provided for `result` is used as the return value of the call + /// to [showMaterial3Search] that launched the search initially. + void close(BuildContext context, T result) { + _currentBody = null; + _focusNode?.unfocus(); + Navigator.of(context) + ..popUntil((Route route) => route == _route) + ..pop(result); + } + + /// The hint text that is shown in the search field when it is empty. + /// + /// If this value is set to null, the value of + /// `MaterialLocalizations.of(context).searchFieldLabel` will be used instead. + final String? searchFieldLabel; + + /// The style of the [searchFieldLabel]. + /// + /// If this value is set to null, the value of the ambient [Theme]'s + /// [InputDecorationTheme.hintStyle] will be used instead. + /// + /// Only one of [searchFieldStyle] or [searchFieldDecorationTheme] can + /// be non-null. + final TextStyle? searchFieldStyle; + + /// The [InputDecorationTheme] used to configure the search field's visuals. + /// + /// Only one of [searchFieldStyle] or [searchFieldDecorationTheme] can + /// be non-null. + final InputDecorationTheme? searchFieldDecorationTheme; + + /// The type of action button to use for the keyboard. + /// + /// Defaults to the default value specified in [TextField]. + final TextInputType? keyboardType; + + /// The text input action configuring the soft keyboard to a particular action + /// button. + /// + /// Defaults to [TextInputAction.search]. + final TextInputAction textInputAction; + + /// [Animation] triggered when the search pages fades in or out. + /// + /// This animation is commonly used to animate [AnimatedIcon]s of + /// [IconButton]s returned by [buildLeading] or [buildActions]. It can also be + /// used to animate [IconButton]s contained within the route below the search + /// page. + Animation get transitionAnimation => _proxyAnimation; + + // The focus node to use for manipulating focus on the search page. This is + // managed, owned, and set by the _SearchPageRoute using this delegate. + FocusNode? _focusNode; + + final TextEditingController _queryTextController = TextEditingController(); + + final ProxyAnimation _proxyAnimation = + ProxyAnimation(kAlwaysDismissedAnimation); + + final ValueNotifier<_SearchBody?> _currentBodyNotifier = + ValueNotifier<_SearchBody?>(null); + + _SearchBody? get _currentBody => _currentBodyNotifier.value; + set _currentBody(_SearchBody? value) { + _currentBodyNotifier.value = value; + } + + _SearchPageRoute? _route; +} + +/// Describes the body that is currently shown under the [AppBar] in the +/// search page. +enum _SearchBody { + /// Suggested queries are shown in the body. + /// + /// The suggested queries are generated by [SearchDelegate.buildSuggestions]. + suggestions, + + /// Search results are currently shown in the body. + /// + /// The search results are generated by [SearchDelegate.buildResults]. + results, +} + +class _SearchPageRoute extends PageRoute { + _SearchPageRoute({ + required this.delegate, + }) : assert(delegate != null) { + assert( + delegate._route == null, + 'The ${delegate.runtimeType} instance is currently used by another active ' + 'search. Please close that search by calling close() on the SearchDelegate ' + 'before opening another search with the same delegate instance.', + ); + delegate._route = this; + } + + final SearchDelegate delegate; + + @override + Color? get barrierColor => null; + + @override + String? get barrierLabel => null; + + @override + Duration get transitionDuration => const Duration(milliseconds: 300); + + @override + bool get maintainState => false; + + @override + Widget buildTransitions( + BuildContext context, + Animation animation, + Animation secondaryAnimation, + Widget child, + ) { + return FadeTransition( + opacity: animation, + child: child, + ); + } + + @override + Animation createAnimation() { + final Animation animation = super.createAnimation(); + delegate._proxyAnimation.parent = animation; + return animation; + } + + @override + Widget buildPage( + BuildContext context, + Animation animation, + Animation secondaryAnimation, + ) { + return _SearchPage( + delegate: delegate, + animation: animation, + ); + } + + @override + void didComplete(T? result) { + super.didComplete(result); + assert(delegate._route == this); + delegate._route = null; + delegate._currentBody = null; + } +} + +class _SearchPage extends StatefulWidget { + const _SearchPage({ + required this.delegate, + required this.animation, + }); + + final SearchDelegate delegate; + final Animation animation; + + @override + State createState() => _SearchPageState(); +} + +class _SearchPageState extends State<_SearchPage> { + // This node is owned, but not hosted by, the search page. Hosting is done by + // the text field. + FocusNode focusNode = FocusNode(); + + @override + void initState() { + super.initState(); + widget.delegate._queryTextController.addListener(_onQueryChanged); + widget.animation.addStatusListener(_onAnimationStatusChanged); + widget.delegate._currentBodyNotifier.addListener(_onSearchBodyChanged); + focusNode.addListener(_onFocusChanged); + widget.delegate._focusNode = focusNode; + } + + @override + void dispose() { + super.dispose(); + widget.delegate._queryTextController.removeListener(_onQueryChanged); + widget.animation.removeStatusListener(_onAnimationStatusChanged); + widget.delegate._currentBodyNotifier.removeListener(_onSearchBodyChanged); + widget.delegate._focusNode = null; + focusNode.dispose(); + } + + void _onAnimationStatusChanged(AnimationStatus status) { + if (status != AnimationStatus.completed) { + return; + } + widget.animation.removeStatusListener(_onAnimationStatusChanged); + if (widget.delegate._currentBody == _SearchBody.suggestions) { + focusNode.requestFocus(); + } + } + + @override + void didUpdateWidget(_SearchPage oldWidget) { + super.didUpdateWidget(oldWidget); + if (widget.delegate != oldWidget.delegate) { + oldWidget.delegate._queryTextController.removeListener(_onQueryChanged); + widget.delegate._queryTextController.addListener(_onQueryChanged); + oldWidget.delegate._currentBodyNotifier + .removeListener(_onSearchBodyChanged); + widget.delegate._currentBodyNotifier.addListener(_onSearchBodyChanged); + oldWidget.delegate._focusNode = null; + widget.delegate._focusNode = focusNode; + } + } + + void _onFocusChanged() { + if (focusNode.hasFocus && + widget.delegate._currentBody != _SearchBody.suggestions) { + widget.delegate.showSuggestions(context); + } + } + + void _onQueryChanged() { + setState(() { + // rebuild ourselves because query changed. + }); + } + + void _onSearchBodyChanged() { + setState(() { + // rebuild ourselves because search body changed. + }); + } + + @override + Widget build(BuildContext context) { + assert(debugCheckHasMaterialLocalizations(context)); + final ThemeData theme = widget.delegate.appBarTheme(context); + final String searchFieldLabel = widget.delegate.searchFieldLabel ?? + MaterialLocalizations.of(context).searchFieldLabel; + Widget? body; + switch (widget.delegate._currentBody) { + case _SearchBody.suggestions: + body = KeyedSubtree( + key: const ValueKey<_SearchBody>(_SearchBody.suggestions), + child: widget.delegate.buildSuggestions(context), + ); + break; + case _SearchBody.results: + body = KeyedSubtree( + key: const ValueKey<_SearchBody>(_SearchBody.results), + child: widget.delegate.buildResults(context), + ); + break; + case null: + break; + } + + late final String routeName; + switch (theme.platform) { + case TargetPlatform.iOS: + case TargetPlatform.macOS: + routeName = ''; + break; + case TargetPlatform.android: + case TargetPlatform.fuchsia: + case TargetPlatform.linux: + case TargetPlatform.windows: + routeName = searchFieldLabel; + } + + return Semantics( + explicitChildNodes: true, + scopesRoute: true, + namesRoute: true, + label: routeName, + child: Theme( + data: theme, + child: Scaffold( + appBar: AppBar( + toolbarHeight: 72, + leading: widget.delegate.buildLeading(context), + title: TextField( + controller: widget.delegate._queryTextController, + focusNode: focusNode, + style: widget.delegate.searchFieldStyle ?? + theme.textTheme.titleLarge, + textInputAction: widget.delegate.textInputAction, + keyboardType: widget.delegate.keyboardType, + onSubmitted: (String _) { + widget.delegate.showResults(context); + }, + decoration: InputDecoration(hintText: searchFieldLabel), + ), + actions: widget.delegate.buildActions(context), + bottom: widget.delegate.buildBottom(context), + ), + body: AnimatedSwitcher( + duration: const Duration(milliseconds: 300), + child: body, + ), + ), + ), + ); + } +} diff --git a/lib/core/widgets/material/search/m3_search_bar.dart b/lib/core/widgets/material/search/m3_search_bar.dart new file mode 100644 index 0000000..1ec56ce --- /dev/null +++ b/lib/core/widgets/material/search/m3_search_bar.dart @@ -0,0 +1,81 @@ +import 'dart:math'; + +import 'package:flutter/material.dart'; + +class SearchBar extends StatelessWidget { + const SearchBar({ + Key? key, + this.height = 56, + required this.leadingIcon, + this.trailingIcon, + required this.supportingText, + required this.onTap, + }) : super(key: key); + + final double height; + double get effectiveHeight { + return max(height, 48); + } + + final VoidCallback onTap; + final Widget leadingIcon; + final Widget? trailingIcon; + + final String supportingText; + + @override + Widget build(BuildContext context) { + 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, + 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, + ), + ), + onTap: onTap, + ), + ), + ), + 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 index bd81066..26d39bc 100644 --- a/lib/features/document_search/cubit/document_search_cubit.dart +++ b/lib/features/document_search/cubit/document_search_cubit.dart @@ -1,29 +1,47 @@ -import 'package:bloc/bloc.dart'; -import 'package:equatable/equatable.dart'; +import 'package:collection/collection.dart'; import 'package:hydrated_bloc/hydrated_bloc.dart'; import 'package:paperless_api/paperless_api.dart'; -import 'package:paperless_api/src/modules/documents_api/paperless_documents_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])); + emit( + state.copyWith( + searchHistory: [ + query, + ...state.searchHistory.where((element) => element != query) + ].take(5).toList(), + ), + ); } - Future updateSuggestions(String query) async { - final suggestions = await api.autocomplete(query); - emit(state.copyWith(suggestions: suggestions)); + void removeHistoryEntry(String suggestion) { + emit(state.copyWith( + searchHistory: state.searchHistory + .whereNot((element) => element == suggestion) + .toList(), + )); + } + + Future> findSuggestions(String query) { + return api.autocomplete(query); } @override diff --git a/lib/features/document_search/cubit/document_search_state.dart b/lib/features/document_search/cubit/document_search_state.dart index a6b0be7..4286fb5 100644 --- a/lib/features/document_search/cubit/document_search_state.dart +++ b/lib/features/document_search/cubit/document_search_state.dart @@ -5,18 +5,13 @@ import 'package:paperless_mobile/features/paged_document_view/model/documents_pa part 'document_search_state.g.dart'; - - @JsonSerializable(ignoreUnannotated: true) class DocumentSearchState extends DocumentsPagedState { @JsonKey() final List searchHistory; - final List suggestions; - const DocumentSearchState({ this.searchHistory = const [], - this.suggestions = const [], super.filter, super.hasLoaded, super.isLoading, @@ -30,7 +25,6 @@ class DocumentSearchState extends DocumentsPagedState { filter, value, searchHistory, - suggestions, ]; @override @@ -62,7 +56,6 @@ class DocumentSearchState extends DocumentsPagedState { hasLoaded: hasLoaded ?? this.hasLoaded, isLoading: isLoading ?? this.isLoading, searchHistory: searchHistory ?? this.searchHistory, - suggestions: suggestions ?? this.suggestions, ); } @@ -71,5 +64,3 @@ class DocumentSearchState extends DocumentsPagedState { Map toJson() => _$DocumentSearchStateToJson(this); } - -class diff --git a/lib/features/document_search/cubit/document_search_state.g.dart b/lib/features/document_search/cubit/document_search_state.g.dart new file mode 100644 index 0000000..6d1bb68 --- /dev/null +++ b/lib/features/document_search/cubit/document_search_state.g.dart @@ -0,0 +1,21 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'document_search_state.dart'; + +// ************************************************************************** +// JsonSerializableGenerator +// ************************************************************************** + +DocumentSearchState _$DocumentSearchStateFromJson(Map json) => + DocumentSearchState( + searchHistory: (json['searchHistory'] as List?) + ?.map((e) => e as String) + .toList() ?? + const [], + ); + +Map _$DocumentSearchStateToJson( + DocumentSearchState instance) => + { + 'searchHistory': instance.searchHistory, + }; diff --git a/lib/features/document_search/document_search_delegate.dart b/lib/features/document_search/document_search_delegate.dart index d70af8b..6e25a6d 100644 --- a/lib/features/document_search/document_search_delegate.dart +++ b/lib/features/document_search/document_search_delegate.dart @@ -3,15 +3,21 @@ 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:provider/provider.dart'; -class DocumentSearchDelegate extends SearchDelegate { - DocumentSearchDelegate({ +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( @@ -23,60 +29,141 @@ class DocumentSearchDelegate extends SearchDelegate { @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) { - BlocBuilder( + return BlocBuilder( + bloc: bloc, builder: (context, state) { - if (!state.hasLoaded && state.isLoading) { - return const DocumentsListLoadingWidget(); - } - return ListView.builder(itemBuilder: (context, index) => ListTile( - title: Text(snapshot.data![index]), - onTap: () { - query = snapshot.data![index]; - super.showResults(context); - }, - ),); - }, - ) - return FutureBuilder( - future: context.read().autocomplete(query), - builder: (context, snapshot) { - if (!snapshot.hasData) { - return const Center( - child: CircularProgressIndicator(), + if (query.isEmpty) { + return CustomScrollView( + slivers: [ + SliverToBoxAdapter( + child: Text( + "History", //TODO: INTL + 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 ListView.builder( - itemCount: snapshot.data!.length, - itemBuilder: (context, index) => ListTile( - title: Text(snapshot.data![index]), - onTap: () { - query = snapshot.data![index]; - super.showResults(context); - }, - ), - ); + 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( + "Results", //TODO: INTL + 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 FutureBuilder( - future: context - .read() - .findAll(DocumentFilter(query: TextQuery.titleAndContent(query))), - builder: (context, snapshot) { - if (!snapshot.hasData) { - return const Center( - child: CircularProgressIndicator(), - ); + return BlocBuilder( + bloc: bloc, + builder: (context, state) { + if (!state.hasLoaded && state.isLoading) { + return const DocumentsListLoadingWidget(); } - final documents = snapshot.data!.results; return ListView.builder( + itemCount: state.documents.length, itemBuilder: (context, index) => DocumentListItem( - document: documents[index], + document: state.documents[index], onTap: (document) { Navigator.push( context, @@ -102,5 +189,18 @@ class DocumentSearchDelegate extends SearchDelegate { } @override - List buildActions(BuildContext context) => []; + 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_app_bar.dart b/lib/features/document_search/view/document_search_app_bar.dart new file mode 100644 index 0000000..2312f3a --- /dev/null +++ b/lib/features/document_search/view/document_search_app_bar.dart @@ -0,0 +1,49 @@ +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 DocumentSearchAppBar extends StatelessWidget { + const DocumentSearchAppBar({ + 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.menu), + 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/view/pages/documents_page.dart b/lib/features/documents/view/pages/documents_page.dart index 75c8da3..2b78798 100644 --- a/lib/features/documents/view/pages/documents_page.dart +++ b/lib/features/documents/view/pages/documents_page.dart @@ -5,10 +5,13 @@ 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/widgets/material/search/m3_search.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/document_search_delegate.dart'; +import 'package:paperless_mobile/features/document_search/view/document_search_app_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/documents_empty_state.dart'; @@ -143,48 +146,14 @@ class _DocumentsPageState extends State { ), appBar: PreferredSize( preferredSize: const Size.fromHeight( - kToolbarHeight + linearProgressIndicatorHeight, + kToolbarHeight, ), child: BlocBuilder( builder: (context, state) { if (state.selection.isEmpty) { return AppBar( - title: TextField( - onTap: () => showSearch( - context: context, - delegate: DocumentSearchDelegate( - searchFieldStyle: - Theme.of(context).textTheme.bodyLarge, - hintText: "Search your documents", - ), - ), - readOnly: true, - decoration: InputDecoration( - hintText: "Search your documents", - hintStyle: Theme.of(context) - .textTheme - .bodyLarge - ?.copyWith( - color: Theme.of(context) - .colorScheme - .onSurfaceVariant), - filled: true, - fillColor: Theme.of(context).colorScheme.surface, - border: OutlineInputBorder( - borderRadius: BorderRadius.circular(32), - borderSide: BorderSide.none, - ), - prefixIcon: IconButton( - icon: const Icon(Icons.menu), - onPressed: () { - Scaffold.of(context).openDrawer(); - }, - ), - ), - ), - // title: Text( - // "${S.of(context).documentsPageTitle} (${_formatDocumentCount(state.count)})", - // ), + automaticallyImplyLeading: false, + title: const DocumentSearchAppBar(), actions: [ const SortDocumentsButton(), BlocBuilder { ), ), ], - bottom: PreferredSize( - preferredSize: const Size.fromHeight( - linearProgressIndicatorHeight), - child: state.isLoading && state.hasLoaded - ? const LinearProgressIndicator() - : const SizedBox(height: 4.0), - ), - automaticallyImplyLeading: false, ); } else { return AppBar( diff --git a/lib/features/home/view/home_page.dart b/lib/features/home/view/home_page.dart index fafd6bd..a49985e 100644 --- a/lib/features/home/view/home_page.dart +++ b/lib/features/home/view/home_page.dart @@ -21,11 +21,14 @@ import 'package:paperless_mobile/features/documents/bloc/documents_cubit.dart'; import 'package:paperless_mobile/features/documents/view/pages/documents_page.dart'; import 'package:paperless_mobile/features/home/view/route_description.dart'; import 'package:paperless_mobile/features/home/view/widget/app_drawer.dart'; +import 'package:paperless_mobile/features/inbox/bloc/inbox_cubit.dart'; +import 'package:paperless_mobile/features/inbox/view/pages/inbox_page.dart'; import 'package:paperless_mobile/features/labels/view/pages/labels_page.dart'; import 'package:paperless_mobile/features/notifications/services/local_notification_service.dart'; import 'package:paperless_mobile/features/saved_view/cubit/saved_view_cubit.dart'; import 'package:paperless_mobile/features/scan/bloc/document_scanner_cubit.dart'; import 'package:paperless_mobile/features/scan/view/scanner_page.dart'; +import 'package:paperless_mobile/features/settings/view/settings_page.dart'; import 'package:paperless_mobile/features/sharing/share_intent_queue.dart'; import 'package:paperless_mobile/features/tasks/cubit/task_status_cubit.dart'; import 'package:paperless_mobile/generated/l10n.dart'; @@ -171,22 +174,22 @@ class _HomePageState extends State { ), label: S.of(context).bottomNavLabelsPageLabel, ), - // RouteDescription( - // icon: const Icon(Icons.inbox_outlined), - // selectedIcon: Icon( - // Icons.inbox, - // color: Theme.of(context).colorScheme.primary, - // ), - // label: S.of(context).bottomNavInboxPageLabel, - // ), - // RouteDescription( - // icon: const Icon(Icons.settings_outlined), - // selectedIcon: Icon( - // Icons.settings, - // color: Theme.of(context).colorScheme.primary, - // ), - // label: S.of(context).appDrawerSettingsLabel, - // ), + RouteDescription( + icon: const Icon(Icons.inbox_outlined), + selectedIcon: Icon( + Icons.inbox, + color: Theme.of(context).colorScheme.primary, + ), + label: S.of(context).bottomNavInboxPageLabel, + ), + RouteDescription( + icon: const Icon(Icons.settings_outlined), + selectedIcon: Icon( + Icons.settings, + color: Theme.of(context).colorScheme.primary, + ), + label: S.of(context).appDrawerSettingsLabel, + ), ]; final routes = [ MultiBlocProvider( @@ -210,6 +213,16 @@ class _HomePageState extends State { child: const ScannerPage(), ), const LabelsPage(), + BlocProvider( + create: (context) => InboxCubit( + context.read(), + context.read(), + context.read(), + context.read(), + ), + child: const InboxPage(), + ), + const SettingsPage(), ]; return MultiBlocListener( listeners: [ @@ -257,6 +270,7 @@ class _HomePageState extends State { } return Scaffold( bottomNavigationBar: NavigationBar( + labelBehavior: NavigationDestinationLabelBehavior.alwaysHide, elevation: 4.0, selectedIndex: _currentIndex, onDestinationSelected: _onNavigationChanged, diff --git a/lib/features/inbox/view/pages/inbox_page.dart b/lib/features/inbox/view/pages/inbox_page.dart index c464e53..55bacb5 100644 --- a/lib/features/inbox/view/pages/inbox_page.dart +++ b/lib/features/inbox/view/pages/inbox_page.dart @@ -30,6 +30,7 @@ class _InboxPageState extends State { @override void initState() { super.initState(); + context.read().initializeInbox(); _scrollController.addListener(_listenForLoadNewData); } diff --git a/lib/l10n/intl_cs.arb b/lib/l10n/intl_cs.arb index 98806d0..5553593 100644 --- a/lib/l10n/intl_cs.arb +++ b/lib/l10n/intl_cs.arb @@ -616,5 +616,6 @@ "colorSchemeOptionClassic": "Classic", "colorSchemeOptionDznamic": "Dynamic", "settingsPageColorSchemeSettingDialogDescription": "Choose between the classic color scheme in Paperless inspired green or use the dynamic color scheme based on your system theme.", - "settingsPageColorSchemeSettingDynamicThemeingVersionMismatchWarning": "Dynamic theming is only supported for devices running Android 12 and above. Using the 'dynamic' option might not have any effect depending on your OS implementation." + "settingsPageColorSchemeSettingDynamicThemeingVersionMismatchWarning": "Dynamic theming is only supported for devices running Android 12 and above. Using the 'dynamic' option might not have any effect depending on your OS implementation.", + "documentSearchPageRemoveFromHistory": "Remove from search history?" } \ No newline at end of file diff --git a/lib/l10n/intl_de.arb b/lib/l10n/intl_de.arb index e32b4e3..dd50fb4 100644 --- a/lib/l10n/intl_de.arb +++ b/lib/l10n/intl_de.arb @@ -616,5 +616,6 @@ "colorSchemeOptionClassic": "Classic", "colorSchemeOptionDznamic": "Dynamic", "settingsPageColorSchemeSettingDialogDescription": "Choose between the classic color scheme in Paperless inspired green or use the dynamic color scheme based on your system theme.", - "settingsPageColorSchemeSettingDynamicThemeingVersionMismatchWarning": "Dynamic theming is only supported for devices running Android 12 and above. Using the 'dynamic' option might not have any effect depending on your OS implementation." + "settingsPageColorSchemeSettingDynamicThemeingVersionMismatchWarning": "Dynamic theming is only supported for devices running Android 12 and above. Using the 'dynamic' option might not have any effect depending on your OS implementation.", + "documentSearchPageRemoveFromHistory": "Remove from search history?" } \ No newline at end of file diff --git a/lib/l10n/intl_en.arb b/lib/l10n/intl_en.arb index ba3400d..04d7266 100644 --- a/lib/l10n/intl_en.arb +++ b/lib/l10n/intl_en.arb @@ -616,5 +616,6 @@ "colorSchemeOptionClassic": "Classic", "colorSchemeOptionDynamic": "Dynamic", "settingsPageColorSchemeSettingDialogDescription": "Choose between a classic color scheme inspired by a traditional Paperless green or use the dynamic color scheme based on your system theme.", - "settingsPageColorSchemeSettingDynamicThemeingVersionMismatchWarning": "Dynamic theming is only supported for devices running Android 12 and above. Selecting the 'Dynamic' option might not have any effect depending on your OS implementation." + "settingsPageColorSchemeSettingDynamicThemeingVersionMismatchWarning": "Dynamic theming is only supported for devices running Android 12 and above. Selecting the 'Dynamic' option might not have any effect depending on your OS implementation.", + "documentSearchPageRemoveFromHistory": "Remove from search history?" } \ No newline at end of file diff --git a/lib/l10n/intl_tr.arb b/lib/l10n/intl_tr.arb index f6a1e0b..230e2d1 100644 --- a/lib/l10n/intl_tr.arb +++ b/lib/l10n/intl_tr.arb @@ -616,5 +616,6 @@ "colorSchemeOptionClassic": "Classic", "colorSchemeOptionDznamic": "Dynamic", "settingsPageColorSchemeSettingDialogDescription": "Choose between the classic color scheme in Paperless inspired green or use the dynamic color scheme based on your system theme.", - "settingsPageColorSchemeSettingDynamicThemeingVersionMismatchWarning": "Dynamic theming is only supported for devices running Android 12 and above. Using the 'dynamic' option might not have any effect depending on your OS implementation." + "settingsPageColorSchemeSettingDynamicThemeingVersionMismatchWarning": "Dynamic theming is only supported for devices running Android 12 and above. Using the 'dynamic' option might not have any effect depending on your OS implementation.", + "documentSearchPageRemoveFromHistory": "Remove from search history?" } \ No newline at end of file diff --git a/lib/theme.dart b/lib/theme.dart index 172978b..27ec7cb 100644 --- a/lib/theme.dart +++ b/lib/theme.dart @@ -26,7 +26,7 @@ ThemeData buildTheme({ final classicScheme = ColorScheme.fromSeed( seedColor: _classicThemeColorSeed, brightness: brightness, - ); + ).harmonized(); late ColorScheme colorScheme; switch (preferredColorScheme) { case ColorSchemeOption.classic: @@ -43,5 +43,8 @@ ThemeData buildTheme({ inputDecorationTheme: _defaultInputDecorationTheme, listTileTheme: _defaultListTileTheme, materialTapTargetSize: MaterialTapTargetSize.shrinkWrap, + appBarTheme: AppBarTheme( + scrolledUnderElevation: 0, + ), ); }