Added search bar on all pages

This commit is contained in:
Anton Stubenbord
2023-02-01 00:27:56 +01:00
parent e9e9fdc336
commit 748cd21713
35 changed files with 1122 additions and 653 deletions

View File

@@ -0,0 +1,68 @@
import 'package:collection/collection.dart';
import 'package:hydrated_bloc/hydrated_bloc.dart';
import 'package:paperless_api/paperless_api.dart';
import 'package:paperless_mobile/features/document_search/cubit/document_search_state.dart';
import 'package:paperless_mobile/features/paged_document_view/paged_documents_mixin.dart';
class DocumentSearchCubit extends HydratedCubit<DocumentSearchState>
with PagedDocumentsMixin {
@override
final PaperlessDocumentsApi api;
DocumentSearchCubit(this.api) : super(const DocumentSearchState());
Future<void> search(String query) async {
emit(state.copyWith(
isLoading: true,
suggestions: [],
view: SearchView.results,
));
final searchFilter = DocumentFilter(
query: TextQuery.titleAndContent(query),
);
await updateFilter(filter: searchFilter);
emit(
state.copyWith(
searchHistory: [
query,
...state.searchHistory
.whereNot((previousQuery) => previousQuery == query)
],
),
);
}
Future<void> suggest(String query) async {
emit(
state.copyWith(
isLoading: true,
view: SearchView.suggestions,
value: [],
suggestions: [],
),
);
final suggestions = await api.autocomplete(query);
emit(state.copyWith(
suggestions: suggestions,
isLoading: false,
));
}
void reset() {
emit(state.copyWith(
view: SearchView.suggestions,
suggestions: [],
isLoading: false,
));
}
@override
DocumentSearchState? fromJson(Map<String, dynamic> json) {
return DocumentSearchState.fromJson(json);
}
@override
Map<String, dynamic>? toJson(DocumentSearchState state) {
return state.toJson();
}
}

View File

@@ -0,0 +1,76 @@
import 'package:equatable/equatable.dart';
import 'package:json_annotation/json_annotation.dart';
import 'package:paperless_api/paperless_api.dart';
import 'package:paperless_mobile/features/paged_document_view/model/paged_documents_state.dart';
part 'document_search_state.g.dart';
enum SearchView {
suggestions,
results;
}
@JsonSerializable(ignoreUnannotated: true)
class DocumentSearchState extends PagedDocumentsState {
@JsonKey()
final List<String> searchHistory;
final SearchView view;
final List<String> suggestions;
const DocumentSearchState({
this.view = SearchView.suggestions,
this.searchHistory = const [],
this.suggestions = const [],
super.filter,
super.hasLoaded,
super.isLoading,
super.value,
});
@override
List<Object?> get props => [
...super.props,
searchHistory,
suggestions,
view,
];
@override
DocumentSearchState copyWithPaged({
bool? hasLoaded,
bool? isLoading,
List<PagedSearchResult<DocumentModel>>? value,
DocumentFilter? filter,
}) {
return copyWith(
hasLoaded: hasLoaded,
isLoading: isLoading,
filter: filter,
value: value,
);
}
DocumentSearchState copyWith({
List<String>? searchHistory,
bool? hasLoaded,
bool? isLoading,
List<PagedSearchResult<DocumentModel>>? value,
DocumentFilter? filter,
List<String>? suggestions,
SearchView? view,
}) {
return DocumentSearchState(
value: value ?? this.value,
filter: filter ?? this.filter,
hasLoaded: hasLoaded ?? this.hasLoaded,
isLoading: isLoading ?? this.isLoading,
searchHistory: searchHistory ?? this.searchHistory,
view: view ?? this.view,
suggestions: suggestions ?? this.suggestions,
);
}
factory DocumentSearchState.fromJson(Map<String, dynamic> json) =>
_$DocumentSearchStateFromJson(json);
Map<String, dynamic> toJson() => _$DocumentSearchStateToJson(this);
}

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

@@ -0,0 +1,179 @@
import 'package:collection/collection.dart';
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:paperless_api/paperless_api.dart';
import 'package:paperless_mobile/extensions/flutter_extensions.dart';
import 'package:paperless_mobile/features/document_search/cubit/document_search_cubit.dart';
import 'package:paperless_mobile/features/document_search/cubit/document_search_state.dart';
import 'package:paperless_mobile/features/documents/view/widgets/adaptive_documents_view.dart';
import 'package:paperless_mobile/generated/l10n.dart';
import 'package:paperless_mobile/routes/document_details_route.dart';
Future<void> showDocumentSearchPage(BuildContext context) {
return Navigator.of(context).push(
MaterialPageRoute(
builder: (context) => BlocProvider(
create: (context) => DocumentSearchCubit(context.read()),
child: const DocumentSearchPage(),
),
),
);
}
class DocumentSearchPage extends StatefulWidget {
const DocumentSearchPage({super.key});
@override
State<DocumentSearchPage> createState() => _DocumentSearchPageState();
}
class _DocumentSearchPageState extends State<DocumentSearchPage> {
final _queryController = TextEditingController(text: '');
String get query => _queryController.text;
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
return Scaffold(
appBar: AppBar(
backgroundColor: theme.colorScheme.surface,
toolbarHeight: 72,
leading: BackButton(
color: theme.colorScheme.onSurface,
),
title: TextField(
autofocus: true,
style: theme.textTheme.bodyLarge?.apply(
color: theme.colorScheme.onSurface,
),
decoration: InputDecoration(
contentPadding: EdgeInsets.zero,
hintStyle: theme.textTheme.bodyLarge?.apply(
color: theme.colorScheme.onSurfaceVariant,
),
hintText: "Search documents", //TODO: INTL
border: InputBorder.none,
),
controller: _queryController,
onChanged: context.read<DocumentSearchCubit>().suggest,
textInputAction: TextInputAction.search,
onSubmitted: (query) {
FocusScope.of(context).unfocus();
context.read<DocumentSearchCubit>().search(query);
},
),
actions: [
IconButton(
color: theme.colorScheme.onSurfaceVariant,
icon: const Icon(Icons.clear),
onPressed: () {
context.read<DocumentSearchCubit>().reset();
_queryController.clear();
},
).padded(),
],
bottom: PreferredSize(
preferredSize: const Size.fromHeight(1),
child: Divider(
color: theme.colorScheme.outline,
),
),
),
body: BlocBuilder<DocumentSearchCubit, DocumentSearchState>(
builder: (context, state) {
switch (state.view) {
case SearchView.suggestions:
return _buildSuggestionsView(state);
case SearchView.results:
return _buildResultsView(state);
}
},
),
);
}
Widget _buildSuggestionsView(DocumentSearchState state) {
final suggestions = state.suggestions
.whereNot((element) => state.searchHistory.contains(element))
.toList();
final historyMatches = state.searchHistory
.where(
(element) => element.startsWith(query),
)
.toList();
return CustomScrollView(
slivers: [
SliverList(
delegate: SliverChildBuilderDelegate(
(context, index) => ListTile(
title: Text(historyMatches[index]),
leading: const Icon(Icons.history),
onTap: () => _selectSuggestion(historyMatches[index]),
),
childCount: historyMatches.length,
),
),
if (state.isLoading)
const SliverToBoxAdapter(
child: Center(
child: CircularProgressIndicator(),
),
)
else
SliverList(
delegate: SliverChildBuilderDelegate(
(context, index) => ListTile(
title: Text(suggestions[index]),
leading: const Icon(Icons.search),
onTap: () => _selectSuggestion(suggestions[index]),
),
childCount: suggestions.length,
),
)
],
);
}
Widget _buildResultsView(DocumentSearchState state) {
final header = Text(
S.of(context).documentSearchResults,
style: Theme.of(context).textTheme.labelSmall,
).padded();
return CustomScrollView(
slivers: [
SliverToBoxAdapter(child: header),
if (state.hasLoaded && !state.isLoading && state.documents.isEmpty)
const SliverToBoxAdapter(
child: Center(child: Text("No documents found.")), //TODO: INTL
)
else
SliverAdaptiveDocumentsView(
documents: state.documents,
hasInternetConnection: true,
isLabelClickable: false,
isLoading: state.isLoading,
hasLoaded: state.hasLoaded,
onTap: (document) async {
final updatedDocument = await Navigator.pushNamed(
context,
DocumentDetailsRoute.routeName,
arguments: DocumentDetailsRouteArguments(
document: document,
isLabelClickable: false,
),
) as DocumentModel?;
if (updatedDocument != document) {
context.read<DocumentSearchCubit>().reload();
}
},
)
],
);
}
void _selectSuggestion(String suggestion) {
_queryController.text = suggestion;
context.read<DocumentSearchCubit>().search(suggestion);
FocusScope.of(context).unfocus();
}
}

View File

@@ -0,0 +1 @@