WIP - Added document search, restructured navigation

This commit is contained in:
Anton Stubenbord
2023-01-24 00:38:37 +01:00
parent f6ecbae6e8
commit e68e3af713
15 changed files with 970 additions and 126 deletions

View 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,
),
),
),
);
}
}

View 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!,
]),
),
),
),
),
);
}
}

View File

@@ -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

View File

@@ -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

View File

@@ -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,
};

View File

@@ -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);
},
),
];
}
} }

View File

@@ -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)})",
// ),
);
}
}

View File

@@ -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(

View File

@@ -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,

View File

@@ -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);
} }

View File

@@ -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?"
} }

View File

@@ -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?"
} }

View File

@@ -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?"
} }

View File

@@ -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?"
} }

View File

@@ -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,
),
); );
} }