mirror of
https://github.com/Xevion/paperless-mobile.git
synced 2025-12-07 20:07:48 -06:00
WIP - Added document search, restructured navigation
This commit is contained in:
601
lib/core/widgets/material/search/m3_search.dart
Normal file
601
lib/core/widgets/material/search/m3_search.dart
Normal file
@@ -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<T?> showMaterial3Search<T>({
|
||||||
|
required BuildContext context,
|
||||||
|
required SearchDelegate<T> delegate,
|
||||||
|
String? query = '',
|
||||||
|
bool useRootNavigator = false,
|
||||||
|
}) {
|
||||||
|
delegate.query = query ?? delegate.query;
|
||||||
|
delegate._currentBody = _SearchBody.suggestions;
|
||||||
|
return Navigator.of(context, rootNavigator: useRootNavigator)
|
||||||
|
.push(_SearchPageRoute<T>(
|
||||||
|
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<T> {
|
||||||
|
/// 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<String> {
|
||||||
|
/// 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<Widget> buildActions(BuildContext context) => <Widget>[];
|
||||||
|
/// }
|
||||||
|
/// ```
|
||||||
|
/// {@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<Widget>? 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<dynamic> 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<double> 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<T>? _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<T> extends PageRoute<T> {
|
||||||
|
_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<T> 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<double> animation,
|
||||||
|
Animation<double> secondaryAnimation,
|
||||||
|
Widget child,
|
||||||
|
) {
|
||||||
|
return FadeTransition(
|
||||||
|
opacity: animation,
|
||||||
|
child: child,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Animation<double> createAnimation() {
|
||||||
|
final Animation<double> animation = super.createAnimation();
|
||||||
|
delegate._proxyAnimation.parent = animation;
|
||||||
|
return animation;
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget buildPage(
|
||||||
|
BuildContext context,
|
||||||
|
Animation<double> animation,
|
||||||
|
Animation<double> secondaryAnimation,
|
||||||
|
) {
|
||||||
|
return _SearchPage<T>(
|
||||||
|
delegate: delegate,
|
||||||
|
animation: animation,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void didComplete(T? result) {
|
||||||
|
super.didComplete(result);
|
||||||
|
assert(delegate._route == this);
|
||||||
|
delegate._route = null;
|
||||||
|
delegate._currentBody = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class _SearchPage<T> extends StatefulWidget {
|
||||||
|
const _SearchPage({
|
||||||
|
required this.delegate,
|
||||||
|
required this.animation,
|
||||||
|
});
|
||||||
|
|
||||||
|
final SearchDelegate<T> delegate;
|
||||||
|
final Animation<double> animation;
|
||||||
|
|
||||||
|
@override
|
||||||
|
State<StatefulWidget> createState() => _SearchPageState<T>();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _SearchPageState<T> extends State<_SearchPage<T>> {
|
||||||
|
// 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<T> 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,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
81
lib/core/widgets/material/search/m3_search_bar.dart
Normal file
81
lib/core/widgets/material/search/m3_search_bar.dart
Normal file
@@ -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!,
|
||||||
|
]),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,29 +1,47 @@
|
|||||||
import 'package:bloc/bloc.dart';
|
import 'package:collection/collection.dart';
|
||||||
import 'package:equatable/equatable.dart';
|
|
||||||
import 'package:hydrated_bloc/hydrated_bloc.dart';
|
import 'package:hydrated_bloc/hydrated_bloc.dart';
|
||||||
import 'package:paperless_api/paperless_api.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 'package:paperless_mobile/features/paged_document_view/documents_paging_mixin.dart';
|
||||||
|
|
||||||
import 'document_search_state.dart';
|
import 'document_search_state.dart';
|
||||||
|
|
||||||
class DocumentSearchCubit extends HydratedCubit<DocumentSearchState>
|
class DocumentSearchCubit extends HydratedCubit<DocumentSearchState>
|
||||||
with DocumentsPagingMixin {
|
with DocumentsPagingMixin {
|
||||||
|
////
|
||||||
DocumentSearchCubit(this.api) : super(const DocumentSearchState());
|
DocumentSearchCubit(this.api) : super(const DocumentSearchState());
|
||||||
|
|
||||||
@override
|
@override
|
||||||
final PaperlessDocumentsApi api;
|
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<void> updateResults(String query) async {
|
Future<void> updateResults(String query) async {
|
||||||
await updateFilter(
|
await updateFilter(
|
||||||
filter: state.filter.copyWith(query: TextQuery.titleAndContent(query)),
|
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<void> updateSuggestions(String query) async {
|
void removeHistoryEntry(String suggestion) {
|
||||||
final suggestions = await api.autocomplete(query);
|
emit(state.copyWith(
|
||||||
emit(state.copyWith(suggestions: suggestions));
|
searchHistory: state.searchHistory
|
||||||
|
.whereNot((element) => element == suggestion)
|
||||||
|
.toList(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<List<String>> findSuggestions(String query) {
|
||||||
|
return api.autocomplete(query);
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
|
|||||||
@@ -5,18 +5,13 @@ import 'package:paperless_mobile/features/paged_document_view/model/documents_pa
|
|||||||
|
|
||||||
part 'document_search_state.g.dart';
|
part 'document_search_state.g.dart';
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
@JsonSerializable(ignoreUnannotated: true)
|
@JsonSerializable(ignoreUnannotated: true)
|
||||||
class DocumentSearchState extends DocumentsPagedState {
|
class DocumentSearchState extends DocumentsPagedState {
|
||||||
@JsonKey()
|
@JsonKey()
|
||||||
final List<String> searchHistory;
|
final List<String> searchHistory;
|
||||||
|
|
||||||
final List<String> suggestions;
|
|
||||||
|
|
||||||
const DocumentSearchState({
|
const DocumentSearchState({
|
||||||
this.searchHistory = const [],
|
this.searchHistory = const [],
|
||||||
this.suggestions = const [],
|
|
||||||
super.filter,
|
super.filter,
|
||||||
super.hasLoaded,
|
super.hasLoaded,
|
||||||
super.isLoading,
|
super.isLoading,
|
||||||
@@ -30,7 +25,6 @@ class DocumentSearchState extends DocumentsPagedState {
|
|||||||
filter,
|
filter,
|
||||||
value,
|
value,
|
||||||
searchHistory,
|
searchHistory,
|
||||||
suggestions,
|
|
||||||
];
|
];
|
||||||
|
|
||||||
@override
|
@override
|
||||||
@@ -62,7 +56,6 @@ class DocumentSearchState extends DocumentsPagedState {
|
|||||||
hasLoaded: hasLoaded ?? this.hasLoaded,
|
hasLoaded: hasLoaded ?? this.hasLoaded,
|
||||||
isLoading: isLoading ?? this.isLoading,
|
isLoading: isLoading ?? this.isLoading,
|
||||||
searchHistory: searchHistory ?? this.searchHistory,
|
searchHistory: searchHistory ?? this.searchHistory,
|
||||||
suggestions: suggestions ?? this.suggestions,
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -71,5 +64,3 @@ class DocumentSearchState extends DocumentsPagedState {
|
|||||||
|
|
||||||
Map<String, dynamic> toJson() => _$DocumentSearchStateToJson(this);
|
Map<String, dynamic> toJson() => _$DocumentSearchStateToJson(this);
|
||||||
}
|
}
|
||||||
|
|
||||||
class
|
|
||||||
|
|||||||
@@ -0,0 +1,21 @@
|
|||||||
|
// GENERATED CODE - DO NOT MODIFY BY HAND
|
||||||
|
|
||||||
|
part of 'document_search_state.dart';
|
||||||
|
|
||||||
|
// **************************************************************************
|
||||||
|
// JsonSerializableGenerator
|
||||||
|
// **************************************************************************
|
||||||
|
|
||||||
|
DocumentSearchState _$DocumentSearchStateFromJson(Map<String, dynamic> json) =>
|
||||||
|
DocumentSearchState(
|
||||||
|
searchHistory: (json['searchHistory'] as List<dynamic>?)
|
||||||
|
?.map((e) => e as String)
|
||||||
|
.toList() ??
|
||||||
|
const [],
|
||||||
|
);
|
||||||
|
|
||||||
|
Map<String, dynamic> _$DocumentSearchStateToJson(
|
||||||
|
DocumentSearchState instance) =>
|
||||||
|
<String, dynamic>{
|
||||||
|
'searchHistory': instance.searchHistory,
|
||||||
|
};
|
||||||
@@ -3,15 +3,21 @@ import 'package:flutter_bloc/flutter_bloc.dart';
|
|||||||
import 'package:paperless_api/paperless_api.dart';
|
import 'package:paperless_api/paperless_api.dart';
|
||||||
import 'package:paperless_mobile/core/repository/provider/label_repositories_provider.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/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/bloc/document_details_cubit.dart';
|
||||||
import 'package:paperless_mobile/features/document_details/view/pages/document_details_page.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_cubit.dart';
|
||||||
import 'package:paperless_mobile/features/document_search/cubit/document_search_state.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/features/documents/view/widgets/list/document_list_item.dart';
|
||||||
import 'package:provider/provider.dart';
|
|
||||||
|
|
||||||
class DocumentSearchDelegate extends SearchDelegate<DocumentModel> {
|
import 'package:paperless_mobile/core/widgets/material/search/m3_search.dart'
|
||||||
DocumentSearchDelegate({
|
as m3;
|
||||||
|
import 'package:paperless_mobile/generated/l10n.dart';
|
||||||
|
|
||||||
|
class DocumentSearchDelegate extends m3.SearchDelegate<DocumentModel> {
|
||||||
|
final DocumentSearchCubit bloc;
|
||||||
|
DocumentSearchDelegate(
|
||||||
|
this.bloc, {
|
||||||
required String hintText,
|
required String hintText,
|
||||||
required super.searchFieldStyle,
|
required super.searchFieldStyle,
|
||||||
}) : super(
|
}) : super(
|
||||||
@@ -23,60 +29,141 @@ class DocumentSearchDelegate extends SearchDelegate<DocumentModel> {
|
|||||||
@override
|
@override
|
||||||
Widget buildLeading(BuildContext context) => const BackButton();
|
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
|
@override
|
||||||
Widget buildSuggestions(BuildContext context) {
|
Widget buildSuggestions(BuildContext context) {
|
||||||
BlocBuilder<DocumentSearchCubit, DocumentSearchState>(
|
return BlocBuilder<DocumentSearchCubit, DocumentSearchState>(
|
||||||
|
bloc: bloc,
|
||||||
builder: (context, state) {
|
builder: (context, state) {
|
||||||
if (!state.hasLoaded && state.isLoading) {
|
if (query.isEmpty) {
|
||||||
return const DocumentsListLoadingWidget();
|
return CustomScrollView(
|
||||||
}
|
slivers: [
|
||||||
return ListView.builder(itemBuilder: (context, index) => ListTile(
|
SliverToBoxAdapter(
|
||||||
title: Text(snapshot.data![index]),
|
child: Text(
|
||||||
onTap: () {
|
"History", //TODO: INTL
|
||||||
query = snapshot.data![index];
|
style: Theme.of(context).textTheme.labelMedium,
|
||||||
super.showResults(context);
|
).padded(16),
|
||||||
},
|
),
|
||||||
),);
|
SliverList(
|
||||||
},
|
delegate: SliverChildBuilderDelegate(
|
||||||
)
|
(context, index) {
|
||||||
return FutureBuilder(
|
final label = state.searchHistory[index];
|
||||||
future: context.read<PaperlessDocumentsApi>().autocomplete(query),
|
return ListTile(
|
||||||
builder: (context, snapshot) {
|
leading: const Icon(Icons.history),
|
||||||
if (!snapshot.hasData) {
|
title: Text(label),
|
||||||
return const Center(
|
onTap: () => _onSuggestionSelected(
|
||||||
child: CircularProgressIndicator(),
|
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(
|
return FutureBuilder<List<String>>(
|
||||||
itemCount: snapshot.data!.length,
|
future: bloc.findSuggestions(query),
|
||||||
itemBuilder: (context, index) => ListTile(
|
builder: (context, snapshot) {
|
||||||
title: Text(snapshot.data![index]),
|
final historyMatches = state.searchHistory
|
||||||
onTap: () {
|
.where((e) => e.startsWith(query))
|
||||||
query = snapshot.data![index];
|
.toList();
|
||||||
super.showResults(context);
|
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
|
@override
|
||||||
Widget buildResults(BuildContext context) {
|
Widget buildResults(BuildContext context) {
|
||||||
return FutureBuilder(
|
return BlocBuilder<DocumentSearchCubit, DocumentSearchState>(
|
||||||
future: context
|
bloc: bloc,
|
||||||
.read<PaperlessDocumentsApi>()
|
builder: (context, state) {
|
||||||
.findAll(DocumentFilter(query: TextQuery.titleAndContent(query))),
|
if (!state.hasLoaded && state.isLoading) {
|
||||||
builder: (context, snapshot) {
|
return const DocumentsListLoadingWidget();
|
||||||
if (!snapshot.hasData) {
|
|
||||||
return const Center(
|
|
||||||
child: CircularProgressIndicator(),
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
final documents = snapshot.data!.results;
|
|
||||||
return ListView.builder(
|
return ListView.builder(
|
||||||
|
itemCount: state.documents.length,
|
||||||
itemBuilder: (context, index) => DocumentListItem(
|
itemBuilder: (context, index) => DocumentListItem(
|
||||||
document: documents[index],
|
document: state.documents[index],
|
||||||
onTap: (document) {
|
onTap: (document) {
|
||||||
Navigator.push<DocumentModel?>(
|
Navigator.push<DocumentModel?>(
|
||||||
context,
|
context,
|
||||||
@@ -102,5 +189,18 @@ class DocumentSearchDelegate extends SearchDelegate<DocumentModel> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
List<Widget> buildActions(BuildContext context) => <Widget>[];
|
List<Widget>? buildActions(BuildContext context) {
|
||||||
|
return [
|
||||||
|
IconButton(
|
||||||
|
icon: Icon(
|
||||||
|
Icons.clear,
|
||||||
|
color: Theme.of(context).colorScheme.onSurfaceVariant,
|
||||||
|
).paddedSymmetrically(horizontal: 16),
|
||||||
|
onPressed: () {
|
||||||
|
query = '';
|
||||||
|
super.showSuggestions(context);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
];
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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)})",
|
||||||
|
// ),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -5,10 +5,13 @@ import 'package:flutter_bloc/flutter_bloc.dart';
|
|||||||
import 'package:paperless_api/paperless_api.dart';
|
import 'package:paperless_api/paperless_api.dart';
|
||||||
import 'package:paperless_mobile/core/bloc/connectivity_cubit.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/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/extensions/flutter_extensions.dart';
|
||||||
import 'package:paperless_mobile/features/document_details/bloc/document_details_cubit.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_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/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_cubit.dart';
|
||||||
import 'package:paperless_mobile/features/documents/bloc/documents_state.dart';
|
import 'package:paperless_mobile/features/documents/bloc/documents_state.dart';
|
||||||
import 'package:paperless_mobile/features/documents/view/widgets/documents_empty_state.dart';
|
import 'package:paperless_mobile/features/documents/view/widgets/documents_empty_state.dart';
|
||||||
@@ -143,48 +146,14 @@ class _DocumentsPageState extends State<DocumentsPage> {
|
|||||||
),
|
),
|
||||||
appBar: PreferredSize(
|
appBar: PreferredSize(
|
||||||
preferredSize: const Size.fromHeight(
|
preferredSize: const Size.fromHeight(
|
||||||
kToolbarHeight + linearProgressIndicatorHeight,
|
kToolbarHeight,
|
||||||
),
|
),
|
||||||
child: BlocBuilder<DocumentsCubit, DocumentsState>(
|
child: BlocBuilder<DocumentsCubit, DocumentsState>(
|
||||||
builder: (context, state) {
|
builder: (context, state) {
|
||||||
if (state.selection.isEmpty) {
|
if (state.selection.isEmpty) {
|
||||||
return AppBar(
|
return AppBar(
|
||||||
title: TextField(
|
automaticallyImplyLeading: false,
|
||||||
onTap: () => showSearch(
|
title: const DocumentSearchAppBar(),
|
||||||
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)})",
|
|
||||||
// ),
|
|
||||||
actions: [
|
actions: [
|
||||||
const SortDocumentsButton(),
|
const SortDocumentsButton(),
|
||||||
BlocBuilder<ApplicationSettingsCubit,
|
BlocBuilder<ApplicationSettingsCubit,
|
||||||
@@ -209,14 +178,6 @@ class _DocumentsPageState extends State<DocumentsPage> {
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
bottom: PreferredSize(
|
|
||||||
preferredSize: const Size.fromHeight(
|
|
||||||
linearProgressIndicatorHeight),
|
|
||||||
child: state.isLoading && state.hasLoaded
|
|
||||||
? const LinearProgressIndicator()
|
|
||||||
: const SizedBox(height: 4.0),
|
|
||||||
),
|
|
||||||
automaticallyImplyLeading: false,
|
|
||||||
);
|
);
|
||||||
} else {
|
} else {
|
||||||
return AppBar(
|
return AppBar(
|
||||||
|
|||||||
@@ -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/documents/view/pages/documents_page.dart';
|
||||||
import 'package:paperless_mobile/features/home/view/route_description.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/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/labels/view/pages/labels_page.dart';
|
||||||
import 'package:paperless_mobile/features/notifications/services/local_notification_service.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/saved_view/cubit/saved_view_cubit.dart';
|
||||||
import 'package:paperless_mobile/features/scan/bloc/document_scanner_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/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/sharing/share_intent_queue.dart';
|
||||||
import 'package:paperless_mobile/features/tasks/cubit/task_status_cubit.dart';
|
import 'package:paperless_mobile/features/tasks/cubit/task_status_cubit.dart';
|
||||||
import 'package:paperless_mobile/generated/l10n.dart';
|
import 'package:paperless_mobile/generated/l10n.dart';
|
||||||
@@ -171,22 +174,22 @@ class _HomePageState extends State<HomePage> {
|
|||||||
),
|
),
|
||||||
label: S.of(context).bottomNavLabelsPageLabel,
|
label: S.of(context).bottomNavLabelsPageLabel,
|
||||||
),
|
),
|
||||||
// RouteDescription(
|
RouteDescription(
|
||||||
// icon: const Icon(Icons.inbox_outlined),
|
icon: const Icon(Icons.inbox_outlined),
|
||||||
// selectedIcon: Icon(
|
selectedIcon: Icon(
|
||||||
// Icons.inbox,
|
Icons.inbox,
|
||||||
// color: Theme.of(context).colorScheme.primary,
|
color: Theme.of(context).colorScheme.primary,
|
||||||
// ),
|
),
|
||||||
// label: S.of(context).bottomNavInboxPageLabel,
|
label: S.of(context).bottomNavInboxPageLabel,
|
||||||
// ),
|
),
|
||||||
// RouteDescription(
|
RouteDescription(
|
||||||
// icon: const Icon(Icons.settings_outlined),
|
icon: const Icon(Icons.settings_outlined),
|
||||||
// selectedIcon: Icon(
|
selectedIcon: Icon(
|
||||||
// Icons.settings,
|
Icons.settings,
|
||||||
// color: Theme.of(context).colorScheme.primary,
|
color: Theme.of(context).colorScheme.primary,
|
||||||
// ),
|
),
|
||||||
// label: S.of(context).appDrawerSettingsLabel,
|
label: S.of(context).appDrawerSettingsLabel,
|
||||||
// ),
|
),
|
||||||
];
|
];
|
||||||
final routes = <Widget>[
|
final routes = <Widget>[
|
||||||
MultiBlocProvider(
|
MultiBlocProvider(
|
||||||
@@ -210,6 +213,16 @@ class _HomePageState extends State<HomePage> {
|
|||||||
child: const ScannerPage(),
|
child: const ScannerPage(),
|
||||||
),
|
),
|
||||||
const LabelsPage(),
|
const LabelsPage(),
|
||||||
|
BlocProvider(
|
||||||
|
create: (context) => InboxCubit(
|
||||||
|
context.read(),
|
||||||
|
context.read(),
|
||||||
|
context.read(),
|
||||||
|
context.read(),
|
||||||
|
),
|
||||||
|
child: const InboxPage(),
|
||||||
|
),
|
||||||
|
const SettingsPage(),
|
||||||
];
|
];
|
||||||
return MultiBlocListener(
|
return MultiBlocListener(
|
||||||
listeners: [
|
listeners: [
|
||||||
@@ -257,6 +270,7 @@ class _HomePageState extends State<HomePage> {
|
|||||||
}
|
}
|
||||||
return Scaffold(
|
return Scaffold(
|
||||||
bottomNavigationBar: NavigationBar(
|
bottomNavigationBar: NavigationBar(
|
||||||
|
labelBehavior: NavigationDestinationLabelBehavior.alwaysHide,
|
||||||
elevation: 4.0,
|
elevation: 4.0,
|
||||||
selectedIndex: _currentIndex,
|
selectedIndex: _currentIndex,
|
||||||
onDestinationSelected: _onNavigationChanged,
|
onDestinationSelected: _onNavigationChanged,
|
||||||
|
|||||||
@@ -30,6 +30,7 @@ class _InboxPageState extends State<InboxPage> {
|
|||||||
@override
|
@override
|
||||||
void initState() {
|
void initState() {
|
||||||
super.initState();
|
super.initState();
|
||||||
|
context.read<InboxCubit>().initializeInbox();
|
||||||
_scrollController.addListener(_listenForLoadNewData);
|
_scrollController.addListener(_listenForLoadNewData);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -616,5 +616,6 @@
|
|||||||
"colorSchemeOptionClassic": "Classic",
|
"colorSchemeOptionClassic": "Classic",
|
||||||
"colorSchemeOptionDznamic": "Dynamic",
|
"colorSchemeOptionDznamic": "Dynamic",
|
||||||
"settingsPageColorSchemeSettingDialogDescription": "Choose between the classic color scheme in Paperless inspired green or use the dynamic color scheme based on your system theme.",
|
"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?"
|
||||||
}
|
}
|
||||||
@@ -616,5 +616,6 @@
|
|||||||
"colorSchemeOptionClassic": "Classic",
|
"colorSchemeOptionClassic": "Classic",
|
||||||
"colorSchemeOptionDznamic": "Dynamic",
|
"colorSchemeOptionDznamic": "Dynamic",
|
||||||
"settingsPageColorSchemeSettingDialogDescription": "Choose between the classic color scheme in Paperless inspired green or use the dynamic color scheme based on your system theme.",
|
"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?"
|
||||||
}
|
}
|
||||||
@@ -616,5 +616,6 @@
|
|||||||
"colorSchemeOptionClassic": "Classic",
|
"colorSchemeOptionClassic": "Classic",
|
||||||
"colorSchemeOptionDynamic": "Dynamic",
|
"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.",
|
"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?"
|
||||||
}
|
}
|
||||||
@@ -616,5 +616,6 @@
|
|||||||
"colorSchemeOptionClassic": "Classic",
|
"colorSchemeOptionClassic": "Classic",
|
||||||
"colorSchemeOptionDznamic": "Dynamic",
|
"colorSchemeOptionDznamic": "Dynamic",
|
||||||
"settingsPageColorSchemeSettingDialogDescription": "Choose between the classic color scheme in Paperless inspired green or use the dynamic color scheme based on your system theme.",
|
"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?"
|
||||||
}
|
}
|
||||||
@@ -26,7 +26,7 @@ ThemeData buildTheme({
|
|||||||
final classicScheme = ColorScheme.fromSeed(
|
final classicScheme = ColorScheme.fromSeed(
|
||||||
seedColor: _classicThemeColorSeed,
|
seedColor: _classicThemeColorSeed,
|
||||||
brightness: brightness,
|
brightness: brightness,
|
||||||
);
|
).harmonized();
|
||||||
late ColorScheme colorScheme;
|
late ColorScheme colorScheme;
|
||||||
switch (preferredColorScheme) {
|
switch (preferredColorScheme) {
|
||||||
case ColorSchemeOption.classic:
|
case ColorSchemeOption.classic:
|
||||||
@@ -43,5 +43,8 @@ ThemeData buildTheme({
|
|||||||
inputDecorationTheme: _defaultInputDecorationTheme,
|
inputDecorationTheme: _defaultInputDecorationTheme,
|
||||||
listTileTheme: _defaultListTileTheme,
|
listTileTheme: _defaultListTileTheme,
|
||||||
materialTapTargetSize: MaterialTapTargetSize.shrinkWrap,
|
materialTapTargetSize: MaterialTapTargetSize.shrinkWrap,
|
||||||
|
appBarTheme: AppBarTheme(
|
||||||
|
scrolledUnderElevation: 0,
|
||||||
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user