mirror of
https://github.com/Xevion/paperless-mobile.git
synced 2025-12-07 09:15:49 -06:00
Added search bar on all pages
This commit is contained in:
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
179
lib/features/document_search/view/document_search_page.dart
Normal file
179
lib/features/document_search/view/document_search_page.dart
Normal 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();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
|
||||
Reference in New Issue
Block a user