mirror of
https://github.com/Xevion/paperless-mobile.git
synced 2025-12-07 07:15:47 -06:00
WIP - Added document search, restructured navigation
This commit is contained in:
@@ -1,29 +1,47 @@
|
||||
import 'package:bloc/bloc.dart';
|
||||
import 'package:equatable/equatable.dart';
|
||||
import 'package:collection/collection.dart';
|
||||
import 'package:hydrated_bloc/hydrated_bloc.dart';
|
||||
import 'package:paperless_api/paperless_api.dart';
|
||||
import 'package:paperless_api/src/modules/documents_api/paperless_documents_api.dart';
|
||||
import 'package:paperless_mobile/features/paged_document_view/documents_paging_mixin.dart';
|
||||
|
||||
import 'document_search_state.dart';
|
||||
|
||||
class DocumentSearchCubit extends HydratedCubit<DocumentSearchState>
|
||||
with DocumentsPagingMixin {
|
||||
////
|
||||
DocumentSearchCubit(this.api) : super(const DocumentSearchState());
|
||||
|
||||
@override
|
||||
final PaperlessDocumentsApi api;
|
||||
|
||||
///
|
||||
/// Requests results based on [query] and adds [query] to the
|
||||
/// search history, removing old occurrences and trimming the list to
|
||||
/// the last 5 searches.
|
||||
///
|
||||
Future<void> updateResults(String query) async {
|
||||
await updateFilter(
|
||||
filter: state.filter.copyWith(query: TextQuery.titleAndContent(query)),
|
||||
);
|
||||
emit(state.copyWith(searchHistory: [query, ...state.searchHistory]));
|
||||
emit(
|
||||
state.copyWith(
|
||||
searchHistory: [
|
||||
query,
|
||||
...state.searchHistory.where((element) => element != query)
|
||||
].take(5).toList(),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> updateSuggestions(String query) async {
|
||||
final suggestions = await api.autocomplete(query);
|
||||
emit(state.copyWith(suggestions: suggestions));
|
||||
void removeHistoryEntry(String suggestion) {
|
||||
emit(state.copyWith(
|
||||
searchHistory: state.searchHistory
|
||||
.whereNot((element) => element == suggestion)
|
||||
.toList(),
|
||||
));
|
||||
}
|
||||
|
||||
Future<List<String>> findSuggestions(String query) {
|
||||
return api.autocomplete(query);
|
||||
}
|
||||
|
||||
@override
|
||||
|
||||
@@ -5,18 +5,13 @@ import 'package:paperless_mobile/features/paged_document_view/model/documents_pa
|
||||
|
||||
part 'document_search_state.g.dart';
|
||||
|
||||
|
||||
|
||||
@JsonSerializable(ignoreUnannotated: true)
|
||||
class DocumentSearchState extends DocumentsPagedState {
|
||||
@JsonKey()
|
||||
final List<String> searchHistory;
|
||||
|
||||
final List<String> suggestions;
|
||||
|
||||
const DocumentSearchState({
|
||||
this.searchHistory = const [],
|
||||
this.suggestions = const [],
|
||||
super.filter,
|
||||
super.hasLoaded,
|
||||
super.isLoading,
|
||||
@@ -30,7 +25,6 @@ class DocumentSearchState extends DocumentsPagedState {
|
||||
filter,
|
||||
value,
|
||||
searchHistory,
|
||||
suggestions,
|
||||
];
|
||||
|
||||
@override
|
||||
@@ -62,7 +56,6 @@ class DocumentSearchState extends DocumentsPagedState {
|
||||
hasLoaded: hasLoaded ?? this.hasLoaded,
|
||||
isLoading: isLoading ?? this.isLoading,
|
||||
searchHistory: searchHistory ?? this.searchHistory,
|
||||
suggestions: suggestions ?? this.suggestions,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -71,5 +64,3 @@ class DocumentSearchState extends DocumentsPagedState {
|
||||
|
||||
Map<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_mobile/core/repository/provider/label_repositories_provider.dart';
|
||||
import 'package:paperless_mobile/core/widgets/documents_list_loading_widget.dart';
|
||||
import 'package:paperless_mobile/extensions/flutter_extensions.dart';
|
||||
import 'package:paperless_mobile/features/document_details/bloc/document_details_cubit.dart';
|
||||
import 'package:paperless_mobile/features/document_details/view/pages/document_details_page.dart';
|
||||
import 'package:paperless_mobile/features/document_search/cubit/document_search_cubit.dart';
|
||||
import 'package:paperless_mobile/features/document_search/cubit/document_search_state.dart';
|
||||
import 'package:paperless_mobile/features/documents/view/widgets/list/document_list_item.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
|
||||
class DocumentSearchDelegate extends SearchDelegate<DocumentModel> {
|
||||
DocumentSearchDelegate({
|
||||
import 'package:paperless_mobile/core/widgets/material/search/m3_search.dart'
|
||||
as m3;
|
||||
import 'package:paperless_mobile/generated/l10n.dart';
|
||||
|
||||
class DocumentSearchDelegate extends m3.SearchDelegate<DocumentModel> {
|
||||
final DocumentSearchCubit bloc;
|
||||
DocumentSearchDelegate(
|
||||
this.bloc, {
|
||||
required String hintText,
|
||||
required super.searchFieldStyle,
|
||||
}) : super(
|
||||
@@ -23,60 +29,141 @@ class DocumentSearchDelegate extends SearchDelegate<DocumentModel> {
|
||||
@override
|
||||
Widget buildLeading(BuildContext context) => const BackButton();
|
||||
|
||||
@override
|
||||
PreferredSizeWidget buildBottom(BuildContext context) => PreferredSize(
|
||||
preferredSize: const Size.fromHeight(1),
|
||||
child: Divider(
|
||||
color: Theme.of(context).colorScheme.outline,
|
||||
height: 1,
|
||||
),
|
||||
);
|
||||
@override
|
||||
Widget buildSuggestions(BuildContext context) {
|
||||
BlocBuilder<DocumentSearchCubit, DocumentSearchState>(
|
||||
return BlocBuilder<DocumentSearchCubit, DocumentSearchState>(
|
||||
bloc: bloc,
|
||||
builder: (context, state) {
|
||||
if (!state.hasLoaded && state.isLoading) {
|
||||
return const DocumentsListLoadingWidget();
|
||||
}
|
||||
return ListView.builder(itemBuilder: (context, index) => ListTile(
|
||||
title: Text(snapshot.data![index]),
|
||||
onTap: () {
|
||||
query = snapshot.data![index];
|
||||
super.showResults(context);
|
||||
},
|
||||
),);
|
||||
},
|
||||
)
|
||||
return FutureBuilder(
|
||||
future: context.read<PaperlessDocumentsApi>().autocomplete(query),
|
||||
builder: (context, snapshot) {
|
||||
if (!snapshot.hasData) {
|
||||
return const Center(
|
||||
child: CircularProgressIndicator(),
|
||||
if (query.isEmpty) {
|
||||
return CustomScrollView(
|
||||
slivers: [
|
||||
SliverToBoxAdapter(
|
||||
child: Text(
|
||||
"History", //TODO: INTL
|
||||
style: Theme.of(context).textTheme.labelMedium,
|
||||
).padded(16),
|
||||
),
|
||||
SliverList(
|
||||
delegate: SliverChildBuilderDelegate(
|
||||
(context, index) {
|
||||
final label = state.searchHistory[index];
|
||||
return ListTile(
|
||||
leading: const Icon(Icons.history),
|
||||
title: Text(label),
|
||||
onTap: () => _onSuggestionSelected(
|
||||
context,
|
||||
label,
|
||||
),
|
||||
onLongPress: () => showDialog(
|
||||
context: context,
|
||||
builder: (context) => AlertDialog(
|
||||
title: Text(label),
|
||||
content: Text(
|
||||
S.of(context).documentSearchPageRemoveFromHistory,
|
||||
),
|
||||
actions: [
|
||||
TextButton(
|
||||
child: Text(
|
||||
S.of(context).genericActionCancelLabel,
|
||||
),
|
||||
onPressed: () => Navigator.pop(context),
|
||||
),
|
||||
TextButton(
|
||||
child: Text(
|
||||
S.of(context).genericActionDeleteLabel,
|
||||
style: TextStyle(
|
||||
color: Theme.of(context).colorScheme.error,
|
||||
),
|
||||
),
|
||||
onPressed: () {
|
||||
bloc.removeHistoryEntry(label);
|
||||
Navigator.pop(context);
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
childCount: state.searchHistory.length,
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
return ListView.builder(
|
||||
itemCount: snapshot.data!.length,
|
||||
itemBuilder: (context, index) => ListTile(
|
||||
title: Text(snapshot.data![index]),
|
||||
onTap: () {
|
||||
query = snapshot.data![index];
|
||||
super.showResults(context);
|
||||
},
|
||||
),
|
||||
);
|
||||
return FutureBuilder<List<String>>(
|
||||
future: bloc.findSuggestions(query),
|
||||
builder: (context, snapshot) {
|
||||
final historyMatches = state.searchHistory
|
||||
.where((e) => e.startsWith(query))
|
||||
.toList();
|
||||
final serverSuggestions = (snapshot.data ?? [])
|
||||
..removeWhere((e) => historyMatches.contains(e));
|
||||
return CustomScrollView(
|
||||
slivers: [
|
||||
SliverToBoxAdapter(
|
||||
child: Text(
|
||||
"Results", //TODO: INTL
|
||||
style: Theme.of(context).textTheme.labelMedium,
|
||||
).padded(),
|
||||
),
|
||||
SliverList(
|
||||
delegate: SliverChildBuilderDelegate(
|
||||
(context, index) => ListTile(
|
||||
title: Text(historyMatches[index]),
|
||||
leading: const Icon(Icons.history),
|
||||
onTap: () => _onSuggestionSelected(
|
||||
context,
|
||||
historyMatches[index],
|
||||
),
|
||||
),
|
||||
childCount: historyMatches.length,
|
||||
),
|
||||
),
|
||||
SliverList(
|
||||
delegate: SliverChildBuilderDelegate(
|
||||
(context, index) => ListTile(
|
||||
title: Text(serverSuggestions[index]),
|
||||
leading: const Icon(Icons.search),
|
||||
onTap: () => _onSuggestionSelected(
|
||||
context, snapshot.data![index]),
|
||||
),
|
||||
childCount: serverSuggestions.length,
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
});
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
void _onSuggestionSelected(BuildContext context, String suggestion) {
|
||||
query = suggestion;
|
||||
bloc.updateResults(query);
|
||||
super.showResults(context);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget buildResults(BuildContext context) {
|
||||
return FutureBuilder(
|
||||
future: context
|
||||
.read<PaperlessDocumentsApi>()
|
||||
.findAll(DocumentFilter(query: TextQuery.titleAndContent(query))),
|
||||
builder: (context, snapshot) {
|
||||
if (!snapshot.hasData) {
|
||||
return const Center(
|
||||
child: CircularProgressIndicator(),
|
||||
);
|
||||
return BlocBuilder<DocumentSearchCubit, DocumentSearchState>(
|
||||
bloc: bloc,
|
||||
builder: (context, state) {
|
||||
if (!state.hasLoaded && state.isLoading) {
|
||||
return const DocumentsListLoadingWidget();
|
||||
}
|
||||
final documents = snapshot.data!.results;
|
||||
return ListView.builder(
|
||||
itemCount: state.documents.length,
|
||||
itemBuilder: (context, index) => DocumentListItem(
|
||||
document: documents[index],
|
||||
document: state.documents[index],
|
||||
onTap: (document) {
|
||||
Navigator.push<DocumentModel?>(
|
||||
context,
|
||||
@@ -102,5 +189,18 @@ class DocumentSearchDelegate extends SearchDelegate<DocumentModel> {
|
||||
}
|
||||
|
||||
@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)})",
|
||||
// ),
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user