WIP - Reimplemented document search

This commit is contained in:
Anton Stubenbord
2023-01-28 23:06:27 +01:00
parent a7b980ae71
commit b697dc7d8d
34 changed files with 949 additions and 677 deletions

View File

@@ -2,7 +2,7 @@ import 'package:flutter/material.dart';
import 'package:paperless_api/paperless_api.dart';
import 'package:paperless_mobile/generated/l10n.dart';
String translateSortField(BuildContext context, SortField sortField) {
String translateSortField(BuildContext context, SortField? sortField) {
switch (sortField) {
case SortField.archiveSerialNumber:
return S.of(context).documentArchiveSerialNumberPropertyShortLabel;
@@ -18,5 +18,7 @@ String translateSortField(BuildContext context, SortField sortField) {
return S.of(context).documentAddedPropertyLabel;
case SortField.modified:
return S.of(context).documentModifiedPropertyLabel;
default:
return '';
}
}

View File

@@ -0,0 +1,217 @@
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:paperless_mobile/constants.dart';
import 'package:paperless_mobile/features/settings/bloc/application_settings_cubit.dart';
import 'package:paperless_mobile/features/settings/bloc/application_settings_state.dart';
import 'package:paperless_mobile/features/settings/model/view_type.dart';
import 'package:paperless_mobile/features/settings/view/settings_page.dart';
import 'package:paperless_mobile/generated/l10n.dart';
import 'package:url_launcher/link.dart';
import 'package:url_launcher/url_launcher_string.dart';
/// Declares selectable actions in menu.
enum AppPopupMenuEntries {
// Documents preview
documentsSelectListView,
documentsSelectGridView,
// Generic actions
openAboutThisAppDialog,
reportBug,
openSettings,
// Adds a divider
divider;
}
class AppOptionsPopupMenu extends StatelessWidget {
final List<AppPopupMenuEntries> displayedActions;
const AppOptionsPopupMenu({
super.key,
required this.displayedActions,
});
@override
Widget build(BuildContext context) {
return PopupMenuButton<AppPopupMenuEntries>(
position: PopupMenuPosition.under,
icon: const Icon(Icons.more_vert),
onSelected: (action) {
switch (action) {
case AppPopupMenuEntries.documentsSelectListView:
context.read<ApplicationSettingsCubit>().setViewType(ViewType.list);
break;
case AppPopupMenuEntries.documentsSelectGridView:
context.read<ApplicationSettingsCubit>().setViewType(ViewType.grid);
break;
case AppPopupMenuEntries.openAboutThisAppDialog:
_showAboutDialog(context);
break;
case AppPopupMenuEntries.openSettings:
Navigator.of(context).push(
MaterialPageRoute(
builder: (context) => BlocProvider.value(
value: context.read<ApplicationSettingsCubit>(),
child: const SettingsPage(),
),
),
);
break;
case AppPopupMenuEntries.reportBug:
launchUrlString(
'https://github.com/astubenbord/paperless-mobile/issues/new',
);
break;
default:
break;
}
},
itemBuilder: _buildEntries,
);
}
PopupMenuItem<AppPopupMenuEntries> _buildReportBugTile(BuildContext context) {
return PopupMenuItem(
value: AppPopupMenuEntries.reportBug,
padding: EdgeInsets.zero,
child: ListTile(
leading: const Icon(Icons.bug_report),
title: Text(S.of(context).appDrawerReportBugLabel),
),
);
}
PopupMenuItem<AppPopupMenuEntries> _buildSettingsTile(BuildContext context) {
return PopupMenuItem(
padding: EdgeInsets.zero,
value: AppPopupMenuEntries.openSettings,
child: ListTile(
leading: const Icon(Icons.settings_outlined),
title: Text(S.of(context).appDrawerSettingsLabel),
),
);
}
PopupMenuItem<AppPopupMenuEntries> _buildAboutTile(BuildContext context) {
return PopupMenuItem(
padding: EdgeInsets.zero,
value: AppPopupMenuEntries.openAboutThisAppDialog,
child: ListTile(
leading: const Icon(Icons.info_outline),
title: Text(S.of(context).appDrawerAboutLabel),
),
);
}
PopupMenuItem<AppPopupMenuEntries> _buildListViewTile() {
return PopupMenuItem(
padding: EdgeInsets.zero,
child: BlocBuilder<ApplicationSettingsCubit, ApplicationSettingsState>(
builder: (context, state) {
return ListTile(
leading: const Icon(Icons.list),
title: const Text("List"),
trailing: state.preferredViewType == ViewType.list
? const Icon(Icons.check)
: null,
);
},
),
value: AppPopupMenuEntries.documentsSelectListView,
);
}
PopupMenuItem<AppPopupMenuEntries> _buildGridViewTile() {
return PopupMenuItem(
value: AppPopupMenuEntries.documentsSelectGridView,
padding: EdgeInsets.zero,
child: BlocBuilder<ApplicationSettingsCubit, ApplicationSettingsState>(
builder: (context, state) {
return ListTile(
leading: const Icon(Icons.grid_view_rounded),
title: const Text("Grid"),
trailing: state.preferredViewType == ViewType.grid
? const Icon(Icons.check)
: null,
);
},
),
);
}
void _showAboutDialog(BuildContext context) {
showAboutDialog(
context: context,
applicationIcon: const ImageIcon(
AssetImage('assets/logos/paperless_logo_green.png'),
),
applicationName: 'Paperless Mobile',
applicationVersion: packageInfo.version + '+' + packageInfo.buildNumber,
children: [
Text(S.of(context).aboutDialogDevelopedByText('Anton Stubenbord')),
Link(
uri: Uri.parse('https://github.com/astubenbord/paperless-mobile'),
builder: (context, followLink) => GestureDetector(
onTap: followLink,
child: Text(
'https://github.com/astubenbord/paperless-mobile',
style: TextStyle(color: Theme.of(context).colorScheme.tertiary),
),
),
),
const SizedBox(height: 16),
Text(
'Credits',
style: Theme.of(context).textTheme.titleMedium,
),
_buildOnboardingImageCredits(),
],
);
}
Widget _buildOnboardingImageCredits() {
return Link(
uri: Uri.parse(
'https://www.freepik.com/free-vector/business-team-working-cogwheel-mechanism-together_8270974.htm#query=setting&position=4&from_view=author'),
builder: (context, followLink) => Wrap(
children: [
const Text('Onboarding images by '),
GestureDetector(
onTap: followLink,
child: Text(
'pch.vector',
style: TextStyle(color: Theme.of(context).colorScheme.tertiary),
),
),
const Text(' on Freepik.')
],
),
);
}
List<PopupMenuEntry<AppPopupMenuEntries>> _buildEntries(
BuildContext context) {
List<PopupMenuEntry<AppPopupMenuEntries>> items = [];
for (final entry in displayedActions) {
switch (entry) {
case AppPopupMenuEntries.documentsSelectListView:
items.add(_buildListViewTile());
break;
case AppPopupMenuEntries.documentsSelectGridView:
items.add(_buildGridViewTile());
break;
case AppPopupMenuEntries.openAboutThisAppDialog:
items.add(_buildAboutTile(context));
break;
case AppPopupMenuEntries.reportBug:
items.add(_buildReportBugTile(context));
break;
case AppPopupMenuEntries.openSettings:
items.add(_buildSettingsTile(context));
break;
case AppPopupMenuEntries.divider:
items.add(const PopupMenuDivider());
break;
}
}
return items;
}
}

View File

@@ -28,51 +28,48 @@ class SearchBar extends StatelessWidget {
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,
return Container(
constraints: const BoxConstraints(minWidth: 360, maxWidth: 720),
width: double.infinity,
height: effectiveHeight,
child: Material(
elevation: 1,
color: colorScheme.surface,
shadowColor: colorScheme.shadow,
surfaceTintColor: colorScheme.surfaceTint,
borderRadius: BorderRadius.circular(effectiveHeight / 2),
child: InkWell(
onTap: () {},
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,
),
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(
readOnly: true,
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,
),
onTap: onTap,
),
),
if (trailingIcon != null) trailingIcon!,
]),
),
),
if (trailingIcon != null) trailingIcon!,
]),
),
),
),

View File

@@ -1,56 +0,0 @@
import 'package:collection/collection.dart';
import 'package:hydrated_bloc/hydrated_bloc.dart';
import 'package:paperless_api/paperless_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.where((element) => element != query)
].take(5).toList(),
),
);
}
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
DocumentSearchState? fromJson(Map<String, dynamic> json) {
return DocumentSearchState.fromJson(json);
}
@override
Map<String, dynamic>? toJson(DocumentSearchState state) {
return state.toJson();
}
}

View File

@@ -1,206 +0,0 @@
import 'package:flutter/material.dart';
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: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(
searchFieldLabel: hintText,
keyboardType: TextInputType.text,
textInputAction: TextInputAction.search,
);
@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) {
return BlocBuilder<DocumentSearchCubit, DocumentSearchState>(
bloc: bloc,
builder: (context, state) {
if (query.isEmpty) {
return CustomScrollView(
slivers: [
SliverToBoxAdapter(
child: Text(
S.of(context).documentSearchHistory,
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 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(
S.of(context).documentSearchResults,
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 BlocBuilder<DocumentSearchCubit, DocumentSearchState>(
bloc: bloc,
builder: (context, state) {
if (!state.hasLoaded && state.isLoading) {
return const DocumentsListLoadingWidget();
}
return ListView.builder(
itemCount: state.documents.length,
itemBuilder: (context, index) => DocumentListItem(
document: state.documents[index],
onTap: (document) {
Navigator.push<DocumentModel?>(
context,
MaterialPageRoute(
builder: (context) => BlocProvider(
create: (context) => DocumentDetailsCubit(
context.read<PaperlessDocumentsApi>(),
document,
),
child: const LabelRepositoriesProvider(
child: DocumentDetailsPage(
isLabelClickable: false,
),
),
),
),
);
},
),
);
},
);
}
@override
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

@@ -1,49 +0,0 @@
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 DocumentSearchBar extends StatelessWidget {
const DocumentSearchBar({
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.search),
onPressed: () {
Scaffold.of(context).openDrawer();
},
),
constraints: const BoxConstraints(maxHeight: 48),
),
// title: Text(
// "${S.of(context).documentsPageTitle} (${_formatDocumentCount(state.count)})",
// ),
);
}
}

View File

@@ -5,10 +5,10 @@ import 'package:hydrated_bloc/hydrated_bloc.dart';
import 'package:paperless_api/paperless_api.dart';
import 'package:paperless_mobile/core/repository/saved_view_repository.dart';
import 'package:paperless_mobile/features/documents/bloc/documents_state.dart';
import 'package:paperless_mobile/features/paged_document_view/documents_paging_mixin.dart';
import 'package:paperless_mobile/features/paged_document_view/paged_documents_mixin.dart';
class DocumentsCubit extends HydratedCubit<DocumentsState>
with DocumentsPagingMixin {
with PagedDocumentsMixin {
@override
final PaperlessDocumentsApi api;

View File

@@ -1,8 +1,8 @@
import 'package:json_annotation/json_annotation.dart';
import 'package:paperless_api/paperless_api.dart';
import 'package:paperless_mobile/features/paged_document_view/model/documents_paged_state.dart';
import 'package:paperless_mobile/features/paged_document_view/model/paged_documents_state.dart';
class DocumentsState extends DocumentsPagedState {
class DocumentsState extends PagedDocumentsState {
final int? selectedSavedViewId;
@JsonKey(ignore: true)

View File

@@ -5,8 +5,10 @@ import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:paperless_api/paperless_api.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/translation/sort_field_localization_mapper.dart';
import 'package:paperless_mobile/core/widgets/app_options_popup_menu.dart';
import 'package:paperless_mobile/core/widgets/material/search/m3_search.dart';
import 'package:paperless_mobile/extensions/flutter_extensions.dart';
import 'package:paperless_mobile/core/widgets/material/search/m3_search_bar.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';
@@ -25,6 +27,7 @@ import 'package:paperless_mobile/features/labels/bloc/providers/labels_bloc_prov
import 'package:paperless_mobile/features/login/bloc/authentication_cubit.dart';
import 'package:paperless_mobile/features/saved_view/cubit/saved_view_cubit.dart';
import 'package:paperless_mobile/features/saved_view/view/saved_view_selection_widget.dart';
import 'package:paperless_mobile/features/search/view/document_search_page.dart';
import 'package:paperless_mobile/features/settings/bloc/application_settings_cubit.dart';
import 'package:paperless_mobile/features/settings/bloc/application_settings_state.dart';
import 'package:paperless_mobile/features/settings/model/view_type.dart';
@@ -32,7 +35,6 @@ import 'package:paperless_mobile/features/tasks/cubit/task_status_cubit.dart';
import 'package:paperless_mobile/generated/l10n.dart';
import 'package:paperless_mobile/helpers/format_helpers.dart';
import 'package:paperless_mobile/helpers/message_helpers.dart';
import 'package:paperless_mobile/constants.dart';
class DocumentFilterIntent {
final DocumentFilter? filter;
@@ -137,142 +139,151 @@ class _DocumentsPageState extends State<DocumentsPage> {
}
},
builder: (context, connectivityState) {
const linearProgressIndicatorHeight = 4.0;
return Scaffold(
drawer: BlocProvider.value(
value: context.read<AuthenticationCubit>(),
child: AppDrawer(
afterInboxClosed: () => context.read<DocumentsCubit>().reload(),
),
),
appBar: PreferredSize(
preferredSize: const Size.fromHeight(
kToolbarHeight,
),
child: BlocBuilder<DocumentsCubit, DocumentsState>(
builder: (context, state) {
if (state.selection.isEmpty) {
return AppBar(
automaticallyImplyLeading: true,
title: Text(S.of(context).documentsPageTitle +
" (${formatMaxCount(state.documents.length)})"),
actions: [
IconButton(
icon: const Icon(Icons.search),
onPressed: () {
showMaterial3Search(
context: context,
delegate: DocumentSearchDelegate(
DocumentSearchCubit(context.read()),
searchFieldStyle:
Theme.of(context).textTheme.bodyLarge,
hintText: "Search documents",
),
);
},
),
const SortDocumentsButton(),
BlocBuilder<ApplicationSettingsCubit,
ApplicationSettingsState>(
builder: (context, settingsState) => IconButton(
icon: Icon(
settingsState.preferredViewType == ViewType.grid
? Icons.list
: Icons.grid_view_rounded,
),
onPressed: () {
// Reset saved view widget position as scroll offset will be reset anyway.
setState(() {
_offset = 0;
_last = 0;
});
final cubit =
context.read<ApplicationSettingsCubit>();
cubit.setViewType(
cubit.state.preferredViewType.toggle());
},
),
),
],
);
} else {
return AppBar(
leading: IconButton(
icon: const Icon(Icons.close),
onPressed: () =>
context.read<DocumentsCubit>().resetSelection(),
),
title: Text(
'${state.selection.length} ${S.of(context).documentsSelectedText}'),
actions: [
IconButton(
icon: const Icon(Icons.delete),
onPressed: () => _onDelete(context, state),
),
],
);
}
},
),
),
floatingActionButton: BlocBuilder<DocumentsCubit, DocumentsState>(
builder: (context, state) {
final appliedFiltersCount = state.filter.appliedFiltersCount;
return b.Badge(
position: b.BadgePosition.topEnd(top: -12, end: -6),
showBadge: appliedFiltersCount > 0,
badgeContent: Text(
'$appliedFiltersCount',
style: const TextStyle(
color: Colors.white,
),
),
animationType: b.BadgeAnimationType.fade,
badgeColor: Colors.red,
child: FloatingActionButton(
child: const Icon(Icons.filter_alt_outlined),
onPressed: _openDocumentFilter,
),
);
},
),
// appBar: PreferredSize(
// preferredSize: const Size.fromHeight(
// kToolbarHeight,
// ),
// child: BlocBuilder<DocumentsCubit, DocumentsState>(
// builder: (context, state) {
// if (state.selection.isEmpty) {
// return DocumentSearchBar();
// // return AppBar(
// // title: Text(S.of(context).documentsPageTitle +
// // " (${formatMaxCount(state.documents.length)})"),
// // actions: [
// // IconButton(
// // icon: const Icon(Icons.search),
// // onPressed: () {
// // showMaterial3Search(
// // context: context,
// // delegate: DocumentSearchDelegate(
// // DocumentSearchCubit(context.read()),
// // searchFieldStyle:
// // Theme.of(context).textTheme.bodyLarge,
// // hintText: "Search documents", //TODO: INTL
// // ),
// // );
// // },
// // ),
// // const SortDocumentsButton(),
// // const AppOptionsPopupMenu(
// // displayedActions: [
// // AppPopupMenuEntries.documentsSelectListView,
// // AppPopupMenuEntries.documentsSelectGridView,
// // AppPopupMenuEntries.divider,
// // AppPopupMenuEntries.openAboutThisAppDialog,
// // AppPopupMenuEntries.reportBug,
// // AppPopupMenuEntries.openSettings,
// // ],
// // ),
// // ],
// // );
// } else {
// return AppBar(
// leading: IconButton(
// icon: const Icon(Icons.close),
// onPressed: () =>
// context.read<DocumentsCubit>().resetSelection(),
// ),
// title: Text(
// '${state.selection.length} ${S.of(context).documentsSelectedText}'),
// actions: [
// IconButton(
// icon: const Icon(Icons.delete),
// onPressed: () => _onDelete(context, state),
// ),
// ],
// );
// }
// },
// ),
// ),
// floatingActionButton: BlocBuilder<DocumentsCubit, DocumentsState>(
// builder: (context, state) {
// final appliedFiltersCount = state.filter.appliedFiltersCount;
// return b.Badge(
// position: b.BadgePosition.topEnd(top: -12, end: -6),
// showBadge: appliedFiltersCount > 0,
// badgeContent: Text(
// '$appliedFiltersCount',
// style: const TextStyle(
// color: Colors.white,
// ),
// ),
// animationType: b.BadgeAnimationType.fade,
// badgeColor: Colors.red,
// child: FloatingActionButton(
// child: const Icon(Icons.filter_alt_outlined),
// onPressed: _openDocumentFilter,
// ),
// );
// },
// ),
resizeToAvoidBottomInset: true,
body: WillPopScope(
onWillPop: () async {
if (context.read<DocumentsCubit>().state.selection.isNotEmpty) {
context.read<DocumentsCubit>().resetSelection();
}
return false;
body: NestedScrollView(
headerSliverBuilder: (context, innerBoxIsScrolled) {
return [
SliverAppBar(
floating: true,
pinned: true,
snap: true,
title: SearchBar(
height: kToolbarHeight - 2,
supportingText: "Search documents",
onTap: () {
showDocumentSearchPage(context);
},
leadingIcon: Icon(Icons.menu),
trailingIcon: CircleAvatar(
child: Text("A"),
),
),
)
];
},
child: RefreshIndicator(
onRefresh: _onRefresh,
notificationPredicate: (_) => connectivityState.isConnected,
child: BlocBuilder<TaskStatusCubit, TaskStatusState>(
builder: (context, taskState) {
return Stack(
children: [
_buildBody(connectivityState),
Positioned(
left: 0,
right: 0,
top: _offset,
child: BlocBuilder<DocumentsCubit, DocumentsState>(
builder: (context, state) {
return ColoredBox(
color: Theme.of(context).colorScheme.background,
child: SavedViewSelectionWidget(
height: _savedViewWidgetHeight,
currentFilter: state.filter,
enabled: state.selection.isEmpty &&
connectivityState.isConnected,
),
);
},
),
),
],
);
},
body: WillPopScope(
onWillPop: () async {
if (context
.read<DocumentsCubit>()
.state
.selection
.isNotEmpty) {
context.read<DocumentsCubit>().resetSelection();
}
return false;
},
child: RefreshIndicator(
onRefresh: _onRefresh,
notificationPredicate: (_) => connectivityState.isConnected,
child: BlocBuilder<TaskStatusCubit, TaskStatusState>(
builder: (context, taskState) {
return _buildBody(connectivityState);
// return Stack(
// children: [
// Positioned(
// left: 0,
// right: 0,
// top: _offset,
// child: BlocBuilder<DocumentsCubit, DocumentsState>(
// builder: (context, state) {
// return ColoredBox(
// color:
// Theme.of(context).colorScheme.background,
// child: SavedViewSelectionWidget(
// height: _savedViewWidgetHeight,
// currentFilter: state.filter,
// enabled: state.selection.isEmpty &&
// connectivityState.isConnected,
// ),
// );
// },
// ),
// ),
// ],
// );
},
),
),
),
),
@@ -282,6 +293,28 @@ class _DocumentsPageState extends State<DocumentsPage> {
);
}
BlocBuilder<ApplicationSettingsCubit, ApplicationSettingsState>
_buildViewTypeButton() {
return BlocBuilder<ApplicationSettingsCubit, ApplicationSettingsState>(
builder: (context, settingsState) => IconButton(
icon: Icon(
settingsState.preferredViewType == ViewType.grid
? Icons.list
: Icons.grid_view_rounded,
),
onPressed: () {
// Reset saved view widget position as scroll offset will be reset anyway.
setState(() {
_offset = 0;
_last = 0;
});
final cubit = context.read<ApplicationSettingsCubit>();
cubit.setViewType(cubit.state.preferredViewType.toggle());
},
),
);
}
void _onDelete(BuildContext context, DocumentsState documentsState) async {
final shouldDelete = await showDialog<bool>(
context: context,
@@ -392,7 +425,26 @@ class _DocumentsPageState extends State<DocumentsPage> {
onDocumentTypeSelected: _addDocumentTypeToFilter,
onStoragePathSelected: _addStoragePathToFilter,
pageLoadingWidget: const NewItemsLoadingWidget(),
beforeItems: const SizedBox(height: _savedViewWidgetHeight),
beforeItems: SizedBox(
height: kToolbarHeight,
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
const SortDocumentsButton(),
IconButton(
icon: Icon(
settings.preferredViewType == ViewType.grid
? Icons.list
: Icons.grid_view_rounded,
),
onPressed: () =>
context.read<ApplicationSettingsCubit>().setViewType(
settings.preferredViewType.toggle(),
),
),
],
),
),
);
},
);

View File

@@ -2,11 +2,11 @@ import 'package:flutter/material.dart';
import 'package:paperless_api/paperless_api.dart';
import 'package:paperless_mobile/core/widgets/empty_state.dart';
import 'package:paperless_mobile/extensions/flutter_extensions.dart';
import 'package:paperless_mobile/features/paged_document_view/model/documents_paged_state.dart';
import 'package:paperless_mobile/features/paged_document_view/model/paged_documents_state.dart';
import 'package:paperless_mobile/generated/l10n.dart';
class DocumentsEmptyState extends StatelessWidget {
final DocumentsPagedState state;
final PagedDocumentsState state;
final VoidCallback onReset;
const DocumentsEmptyState({
Key? key,

View File

@@ -9,7 +9,7 @@ import 'package:paperless_mobile/features/settings/model/view_type.dart';
class AdaptiveDocumentsView extends StatelessWidget {
final DocumentsState state;
final ViewType viewType;
final Widget beforeItems;
final Widget? beforeItems;
final void Function(DocumentModel) onTap;
final void Function(DocumentModel) onSelected;
final ScrollController scrollController;
@@ -34,7 +34,7 @@ class AdaptiveDocumentsView extends StatelessWidget {
this.onDocumentTypeSelected,
this.onStoragePathSelected,
required this.pageLoadingWidget,
required this.beforeItems,
this.beforeItems,
required this.viewType,
});

View File

@@ -4,51 +4,60 @@ import 'package:paperless_api/paperless_api.dart';
import 'package:paperless_mobile/core/repository/label_repository.dart';
import 'package:paperless_mobile/core/repository/state/impl/correspondent_repository_state.dart';
import 'package:paperless_mobile/core/repository/state/impl/document_type_repository_state.dart';
import 'package:paperless_mobile/core/translation/sort_field_localization_mapper.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/view/widgets/search/sort_field_selection_bottom_sheet.dart';
import 'package:paperless_mobile/features/labels/bloc/label_cubit.dart';
class SortDocumentsButton extends StatelessWidget {
const SortDocumentsButton({super.key});
const SortDocumentsButton({
super.key,
});
@override
Widget build(BuildContext context) {
return IconButton(
icon: const Icon(Icons.sort),
onPressed: () {
showModalBottomSheet(
elevation: 2,
context: context,
isScrollControlled: true,
shape: const RoundedRectangleBorder(
borderRadius: BorderRadius.only(
topLeft: Radius.circular(16),
topRight: Radius.circular(16),
),
),
builder: (_) => BlocProvider<DocumentsCubit>.value(
value: context.read<DocumentsCubit>(),
child: MultiBlocProvider(
providers: [
BlocProvider(
create: (context) => LabelCubit<DocumentType>(
context.read<
LabelRepository<DocumentType,
DocumentTypeRepositoryState>>(),
),
return BlocBuilder<DocumentsCubit, DocumentsState>(
builder: (context, state) {
if (state.filter.sortField == null) {
return const SizedBox.shrink();
}
return TextButton.icon(
icon: Icon(state.filter.sortOrder == SortOrder.ascending
? Icons.arrow_upward
: Icons.arrow_downward),
label: Text(translateSortField(context, state.filter.sortField)),
onPressed: () {
showModalBottomSheet(
elevation: 2,
context: context,
isScrollControlled: true,
shape: const RoundedRectangleBorder(
borderRadius: BorderRadius.only(
topLeft: Radius.circular(16),
topRight: Radius.circular(16),
),
BlocProvider(
create: (context) => LabelCubit<Correspondent>(
context.read<
LabelRepository<Correspondent,
CorrespondentRepositoryState>>(),
),
),
],
child: BlocBuilder<DocumentsCubit, DocumentsState>(
builder: (context, state) {
return SortFieldSelectionBottomSheet(
),
builder: (_) => BlocProvider<DocumentsCubit>.value(
value: context.read<DocumentsCubit>(),
child: MultiBlocProvider(
providers: [
BlocProvider(
create: (context) => LabelCubit<DocumentType>(
context.read<
LabelRepository<DocumentType,
DocumentTypeRepositoryState>>(),
),
),
BlocProvider(
create: (context) => LabelCubit<Correspondent>(
context.read<
LabelRepository<Correspondent,
CorrespondentRepositoryState>>(),
),
),
],
child: SortFieldSelectionBottomSheet(
initialSortField: state.filter.sortField,
initialSortOrder: state.filter.sortOrder,
onSubmit: (field, order) =>
@@ -58,11 +67,11 @@ class SortDocumentsButton extends StatelessWidget {
sortOrder: order,
),
),
);
},
),
),
),
),
),
);
},
);
},
);

View File

@@ -249,7 +249,7 @@ class _HomePageState extends State<HomePage> {
builder: (context, sizingInformation) {
if (!sizingInformation.isMobile) {
return Scaffold(
drawer: const AppDrawer(),
// drawer: const AppDrawer(),
body: Row(
children: [
NavigationRail(

View File

@@ -317,53 +317,5 @@ class _AppDrawerState extends State<AppDrawer> {
);
}
Link _buildOnboardingImageCredits() {
return Link(
uri: Uri.parse(
'https://www.freepik.com/free-vector/business-team-working-cogwheel-mechanism-together_8270974.htm#query=setting&position=4&from_view=author'),
builder: (context, followLink) => Wrap(
children: [
const Text('Onboarding images by '),
GestureDetector(
onTap: followLink,
child: Text(
'pch.vector',
style: TextStyle(color: Theme.of(context).colorScheme.tertiary),
),
),
const Text(' on Freepik.')
],
),
);
}
void _onShowAboutDialog() {
showAboutDialog(
context: context,
applicationIcon: const ImageIcon(
AssetImage('assets/logos/paperless_logo_green.png'),
),
applicationName: 'Paperless Mobile',
applicationVersion: packageInfo.version + '+' + packageInfo.buildNumber,
children: [
Text(S.of(context).aboutDialogDevelopedByText('Anton Stubenbord')),
Link(
uri: Uri.parse('https://github.com/astubenbord/paperless-mobile'),
builder: (context, followLink) => GestureDetector(
onTap: followLink,
child: Text(
'https://github.com/astubenbord/paperless-mobile',
style: TextStyle(color: Theme.of(context).colorScheme.tertiary),
),
),
),
const SizedBox(height: 16),
Text(
'Credits',
style: Theme.of(context).textTheme.titleMedium,
),
_buildOnboardingImageCredits(),
],
);
}
void _onShowAboutDialog() {}
}

View File

@@ -7,9 +7,9 @@ import 'package:paperless_mobile/core/repository/state/impl/correspondent_reposi
import 'package:paperless_mobile/core/repository/state/impl/document_type_repository_state.dart';
import 'package:paperless_mobile/core/repository/state/impl/tag_repository_state.dart';
import 'package:paperless_mobile/features/inbox/bloc/state/inbox_state.dart';
import 'package:paperless_mobile/features/paged_document_view/documents_paging_mixin.dart';
import 'package:paperless_mobile/features/paged_document_view/paged_documents_mixin.dart';
class InboxCubit extends HydratedCubit<InboxState> with DocumentsPagingMixin {
class InboxCubit extends HydratedCubit<InboxState> with PagedDocumentsMixin {
final LabelRepository<Tag, TagRepositoryState> _tagsRepository;
final LabelRepository<Correspondent, CorrespondentRepositoryState>
_correspondentRepository;

View File

@@ -1,13 +1,13 @@
import 'package:json_annotation/json_annotation.dart';
import 'package:paperless_api/paperless_api.dart';
import 'package:paperless_mobile/features/paged_document_view/model/documents_paged_state.dart';
import 'package:paperless_mobile/features/paged_document_view/model/paged_documents_state.dart';
part 'inbox_state.g.dart';
@JsonSerializable(
ignoreUnannotated: true,
)
class InboxState extends DocumentsPagedState {
class InboxState extends PagedDocumentsState {
final Iterable<int> inboxTags;
final Map<int, Tag> availableTags;

View File

@@ -72,10 +72,10 @@ class _InboxPageState extends State<InboxPage> {
child: ColoredBox(
color: Theme.of(context).colorScheme.secondaryContainer,
child: Text(
state.value.isEmpty
? '0'
: '${state.value.first.count} ' +
S.of(context).inboxPageUnseenText,
(state.value.isEmpty
? '0 '
: '${state.value.first.count} ') +
S.of(context).inboxPageUnseenText,
textAlign: TextAlign.start,
style: Theme.of(context).textTheme.bodySmall,
).paddedSymmetrically(horizontal: 4.0),

View File

@@ -51,7 +51,6 @@ class _LabelsPageState extends State<LabelsPage>
child: BlocBuilder<ConnectivityCubit, ConnectivityState>(
builder: (context, connectedState) {
return Scaffold(
drawer: const AppDrawer(),
appBar: AppBar(
title: Text(
[

View File

@@ -1,25 +1,15 @@
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:paperless_api/paperless_api.dart';
import 'package:paperless_mobile/features/linked_documents/bloc/state/linked_documents_state.dart';
import 'package:paperless_mobile/features/paged_document_view/paged_documents_mixin.dart';
class LinkedDocumentsCubit extends Cubit<LinkedDocumentsState> {
final PaperlessDocumentsApi _api;
class LinkedDocumentsCubit extends Cubit<LinkedDocumentsState>
with PagedDocumentsMixin {
@override
final PaperlessDocumentsApi api;
LinkedDocumentsCubit(this._api, DocumentFilter filter)
: super(LinkedDocumentsState(filter: filter)) {
_initialize();
}
Future<void> _initialize() async {
final documents = await _api.findAll(
state.filter.copyWith(
pageSize: 100,
),
);
emit(LinkedDocumentsState(
isLoaded: true,
documents: documents,
filter: state.filter,
));
LinkedDocumentsCubit(this.api, DocumentFilter filter)
: super(const LinkedDocumentsState()) {
updateFilter(filter: filter);
}
}

View File

@@ -1,13 +1,48 @@
import 'package:paperless_api/paperless_api.dart';
import 'package:paperless_mobile/features/paged_document_view/model/paged_documents_state.dart';
class LinkedDocumentsState {
final bool isLoaded;
final PagedSearchResult<DocumentModel>? documents;
final DocumentFilter filter;
LinkedDocumentsState({
required this.filter,
this.isLoaded = false,
this.documents,
class LinkedDocumentsState extends PagedDocumentsState {
const LinkedDocumentsState({
super.filter,
super.isLoading,
super.hasLoaded,
super.value,
});
LinkedDocumentsState copyWith({
DocumentFilter? filter,
bool? isLoading,
bool? hasLoaded,
List<PagedSearchResult<DocumentModel>>? value,
}) {
return LinkedDocumentsState(
filter: filter ?? this.filter,
isLoading: isLoading ?? this.isLoading,
hasLoaded: hasLoaded ?? this.hasLoaded,
value: value ?? this.value,
);
}
@override
LinkedDocumentsState copyWithPaged({
bool? hasLoaded,
bool? isLoading,
List<PagedSearchResult<DocumentModel>>? value,
DocumentFilter? filter,
}) {
return copyWith(
hasLoaded: hasLoaded,
isLoading: isLoading,
value: value,
filter: filter,
);
}
@override
List<Object?> get props => [
filter,
isLoading,
hasLoaded,
value,
];
}

View File

@@ -8,6 +8,7 @@ import 'package:paperless_mobile/features/documents/view/widgets/list/document_l
import 'package:paperless_mobile/features/linked_documents/bloc/linked_documents_cubit.dart';
import 'package:paperless_mobile/features/linked_documents/bloc/state/linked_documents_state.dart';
import 'package:paperless_mobile/generated/l10n.dart';
import 'package:paperless_mobile/helpers/message_helpers.dart';
class LinkedDocumentsPage extends StatefulWidget {
const LinkedDocumentsPage({super.key});
@@ -17,6 +18,28 @@ class LinkedDocumentsPage extends StatefulWidget {
}
class _LinkedDocumentsPageState extends State<LinkedDocumentsPage> {
final _scrollController = ScrollController();
@override
void initState() {
super.initState();
_scrollController.addListener(_listenForLoadNewData);
}
void _listenForLoadNewData() async {
final currState = context.read<LinkedDocumentsCubit>().state;
if (_scrollController.offset >=
_scrollController.position.maxScrollExtent * 0.75 &&
!currState.isLoading &&
!currState.isLastPageLoaded) {
try {
await context.read<LinkedDocumentsCubit>().loadMore();
} on PaperlessServerException catch (error, stackTrace) {
showErrorMessage(context, error, stackTrace);
}
}
}
@override
Widget build(BuildContext context) {
return Scaffold(
@@ -25,45 +48,14 @@ class _LinkedDocumentsPageState extends State<LinkedDocumentsPage> {
),
body: BlocBuilder<LinkedDocumentsCubit, LinkedDocumentsState>(
builder: (context, state) {
return Column(
children: [
Text(
S.of(context).referencedDocumentsReadOnlyHintText,
textAlign: TextAlign.center,
style: Theme.of(context).textTheme.bodySmall,
),
if (!state.isLoaded)
const Expanded(child: DocumentsListLoadingWidget())
else
Expanded(
child: ListView.builder(
itemCount: state.documents?.results.length,
itemBuilder: (context, index) {
return DocumentListItem(
isLabelClickable: false,
document: state.documents!.results.elementAt(index),
onTap: (doc) {
Navigator.push(
context,
MaterialPageRoute(
builder: (context) => BlocProvider(
create: (context) => DocumentDetailsCubit(
context.read<PaperlessDocumentsApi>(),
state.documents!.results.elementAt(index),
),
child: const DocumentDetailsPage(
isLabelClickable: false,
allowEdit: false,
),
),
),
);
},
);
},
),
),
],
if (!state.hasLoaded) {
return const DocumentsListLoadingWidget();
}
return ListView.builder(
itemCount: state.documents.length,
itemBuilder: (context, index) => DocumentListItem(
document: state.documents[index],
),
);
},
),

View File

@@ -5,13 +5,13 @@ import 'package:paperless_api/paperless_api.dart';
/// Base state for all blocs/cubits using a paged view of documents.
/// [T] is the return type of the API call.
///
abstract class DocumentsPagedState extends Equatable {
abstract class PagedDocumentsState extends Equatable {
final bool hasLoaded;
final bool isLoading;
final List<PagedSearchResult<DocumentModel>> value;
final DocumentFilter filter;
const DocumentsPagedState({
const PagedDocumentsState({
this.value = const [],
this.hasLoaded = false,
this.isLoading = false,
@@ -71,4 +71,12 @@ abstract class DocumentsPagedState extends Equatable {
List<PagedSearchResult<DocumentModel>>? value,
DocumentFilter? filter,
});
@override
List<Object?> get props => [
filter,
value,
hasLoaded,
isLoading,
];
}

View File

@@ -2,12 +2,12 @@ import 'package:collection/collection.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:paperless_api/paperless_api.dart';
import 'model/documents_paged_state.dart';
import 'model/paged_documents_state.dart';
///
/// Mixin which can be used on cubits which handle documents. This implements all paging and filtering logic.
///
mixin DocumentsPagingMixin<State extends DocumentsPagedState>
mixin PagedDocumentsMixin<State extends PagedDocumentsState>
on BlocBase<State> {
PaperlessDocumentsApi get api;

View File

@@ -47,7 +47,6 @@ class _ScannerPageState extends State<ScannerPage>
return BlocBuilder<ConnectivityCubit, ConnectivityState>(
builder: (context, connectedState) {
return Scaffold(
drawer: const AppDrawer(),
floatingActionButton: FloatingActionButton(
onPressed: () => _openDocumentScanner(context),
child: const Icon(Icons.add_a_photo_outlined),

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/paged_document_view/paged_documents_mixin.dart';
import 'package:paperless_mobile/features/search/cubit/document_search_state.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

@@ -1,17 +1,25 @@
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/documents_paged_state.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 DocumentsPagedState {
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,
@@ -25,6 +33,8 @@ class DocumentSearchState extends DocumentsPagedState {
filter,
value,
searchHistory,
suggestions,
view,
];
@override
@@ -49,6 +59,7 @@ class DocumentSearchState extends DocumentsPagedState {
List<PagedSearchResult<DocumentModel>>? value,
DocumentFilter? filter,
List<String>? suggestions,
SearchView? view,
}) {
return DocumentSearchState(
value: value ?? this.value,
@@ -56,6 +67,8 @@ class DocumentSearchState extends DocumentsPagedState {
hasLoaded: hasLoaded ?? this.hasLoaded,
isLoading: isLoading ?? this.isLoading,
searchHistory: searchHistory ?? this.searchHistory,
view: view ?? this.view,
suggestions: suggestions ?? this.suggestions,
);
}

View File

@@ -0,0 +1,166 @@
import 'package:collection/collection.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:flutter/widgets.dart';
import 'package:flutter_bloc/flutter_bloc.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/documents/view/widgets/list/document_list_item.dart';
import 'package:paperless_mobile/features/search/cubit/document_search_state.dart';
import 'package:paperless_mobile/features/search/cubit/document_search_cubit.dart';
import 'package:paperless_mobile/generated/l10n.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(
hintStyle: theme.textTheme.bodyLarge?.apply(
color: theme.colorScheme.onSurfaceVariant,
),
hintText: "Search documents",
border: InputBorder.none,
),
controller: _queryController,
onChanged: context.read<DocumentSearchCubit>().suggest,
onSubmitted: context.read<DocumentSearchCubit>().search,
),
actions: [
IconButton(
color: theme.colorScheme.onSurfaceVariant,
icon: Icon(Icons.clear),
onPressed: () {
context.read<DocumentSearchCubit>().reset();
_queryController.clear();
},
)
],
bottom: PreferredSize(
preferredSize: 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: 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: 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();
if (state.isLoading) {
return DocumentsListLoadingWidget(
beforeWidgets: [header],
);
}
return CustomScrollView(
slivers: [
SliverToBoxAdapter(child: header),
if (state.hasLoaded && !state.isLoading && state.documents.isEmpty)
SliverToBoxAdapter(
child: Center(child: Text("No documents found.")),
)
else
SliverList(
delegate: SliverChildBuilderDelegate(
(context, index) => DocumentListItem(
document: state.documents[index],
),
childCount: state.documents.length,
),
),
],
);
}
void _selectSuggestion(String suggestion) {
context.read<DocumentSearchCubit>().search(suggestion);
}
}

View File

@@ -4,6 +4,7 @@ import 'package:flutter/material.dart';
import 'package:json_annotation/json_annotation.dart';
import 'package:paperless_mobile/features/settings/model/color_scheme_option.dart';
import 'package:paperless_mobile/features/settings/model/view_type.dart';
import 'package:paperless_mobile/generated/l10n.dart';
part 'application_settings_state.g.dart';
@@ -13,7 +14,7 @@ part 'application_settings_state.g.dart';
@JsonSerializable()
class ApplicationSettingsState {
static final defaultSettings = ApplicationSettingsState(
preferredLocaleSubtag: Platform.localeName.split('_').first,
preferredLocaleSubtag: _defaultPreferredLocaleSubtag,
);
final bool isLocalAuthenticationEnabled;
@@ -52,4 +53,13 @@ class ApplicationSettingsState {
preferredColorSchemeOption ?? this.preferredColorSchemeOption,
);
}
static String get _defaultPreferredLocaleSubtag {
String preferredLocale = Platform.localeName.split("_").first;
if (!S.delegate.supportedLocales
.any((locale) => locale.languageCode == preferredLocale)) {
preferredLocale = 'en';
}
return preferredLocale;
}
}

View File

@@ -1,10 +1,22 @@
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:hydrated_bloc/hydrated_bloc.dart';
import 'package:paperless_api/paperless_api.dart';
import 'package:paperless_mobile/core/bloc/paperless_server_information_cubit.dart';
import 'package:paperless_mobile/core/bloc/paperless_server_information_state.dart';
import 'package:paperless_mobile/core/repository/label_repository.dart';
import 'package:paperless_mobile/core/repository/saved_view_repository.dart';
import 'package:paperless_mobile/core/repository/state/impl/correspondent_repository_state.dart';
import 'package:paperless_mobile/core/repository/state/impl/document_type_repository_state.dart';
import 'package:paperless_mobile/core/repository/state/impl/storage_path_repository_state.dart';
import 'package:paperless_mobile/core/repository/state/impl/tag_repository_state.dart';
import 'package:paperless_mobile/features/login/bloc/authentication_cubit.dart';
import 'package:paperless_mobile/features/settings/bloc/application_settings_cubit.dart';
import 'package:paperless_mobile/features/settings/view/pages/application_settings_page.dart';
import 'package:paperless_mobile/features/settings/view/pages/security_settings_page.dart';
import 'package:paperless_mobile/features/settings/view/pages/storage_settings_page.dart';
import 'package:paperless_mobile/generated/l10n.dart';
import 'package:paperless_mobile/helpers/message_helpers.dart';
class SettingsPage extends StatelessWidget {
const SettingsPage({super.key});
@@ -14,22 +26,58 @@ class SettingsPage extends StatelessWidget {
return Scaffold(
appBar: AppBar(
title: Text(S.of(context).appDrawerSettingsLabel),
actions: [
IconButton(
icon: const Icon(Icons.logout),
color: Theme.of(context).colorScheme.error,
onPressed: () async {
await _onLogout(context);
Navigator.pop(context);
},
),
],
),
bottomNavigationBar: BlocBuilder<PaperlessServerInformationCubit,
PaperlessServerInformationState>(
builder: (context, state) {
final info = state.information!;
return ListTile(
title: Text(
S.of(context).appDrawerHeaderLoggedInAsText +
" " +
(info.username ?? 'unknown') +
"@${info.host}",
style: Theme.of(context).textTheme.bodySmall,
),
subtitle: Text(
S.of(context).serverInformationPaperlessVersionText +
' ' +
info.version.toString() +
' (API v${info.apiVersion})',
style: Theme.of(context).textTheme.bodySmall,
),
);
},
),
body: ListView(
children: [
ListTile(
// leading: const Icon(Icons.style_outlined),
title: Text(S.of(context).settingsPageApplicationSettingsLabel),
subtitle: Text(
S.of(context).settingsPageApplicationSettingsDescriptionText),
onTap: () => _goto(const ApplicationSettingsPage(), context),
),
ListTile(
// leading: const Icon(Icons.security_outlined),
title: Text(S.of(context).settingsPageSecuritySettingsLabel),
subtitle:
Text(S.of(context).settingsPageSecuritySettingsDescriptionText),
onTap: () => _goto(const SecuritySettingsPage(), context),
),
ListTile(
// leading: const Icon(Icons.storage_outlined),
title: Text(S.of(context).settingsPageStorageSettingsLabel),
subtitle:
Text(S.of(context).settingsPageStorageSettingsDescriptionText),
@@ -52,4 +100,25 @@ class SettingsPage extends StatelessWidget {
),
);
}
Future<void> _onLogout(BuildContext context) async {
try {
await context.read<AuthenticationCubit>().logout();
await context.read<ApplicationSettingsCubit>().clear();
await context.read<LabelRepository<Tag, TagRepositoryState>>().clear();
await context
.read<LabelRepository<Correspondent, CorrespondentRepositoryState>>()
.clear();
await context
.read<LabelRepository<DocumentType, DocumentTypeRepositoryState>>()
.clear();
await context
.read<LabelRepository<StoragePath, StoragePathRepositoryState>>()
.clear();
await context.read<SavedViewRepository>().clear();
await HydratedBloc.storage.clear();
} on PaperlessServerException catch (error, stackTrace) {
showErrorMessage(context, error, stackTrace);
}
}
}

View File

@@ -1,5 +1,6 @@
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:paperless_mobile/core/widgets/hint_card.dart';
import 'package:paperless_mobile/features/settings/bloc/application_settings_cubit.dart';
import 'package:paperless_mobile/features/settings/bloc/application_settings_state.dart';
import 'package:paperless_mobile/features/settings/view/widgets/radio_settings_dialog.dart';
@@ -20,6 +21,7 @@ class _LanguageSelectionSettingState extends State<LanguageSelectionSetting> {
'cs': 'Česky',
'tr': 'Türkçe',
};
@override
Widget build(BuildContext context) {
return BlocBuilder<ApplicationSettingsCubit, ApplicationSettingsState>(
@@ -27,9 +29,12 @@ class _LanguageSelectionSettingState extends State<LanguageSelectionSetting> {
return ListTile(
title: Text(S.of(context).settingsPageLanguageSettingLabel),
subtitle: Text(_languageOptions[settings.preferredLocaleSubtag]!),
onTap: () => showDialog(
onTap: () => showDialog<String>(
context: context,
builder: (_) => RadioSettingsDialog<String>(
footer: const Text(
"* Work in progress, not fully translated yet. Some words may be displayed in English!",
),
titleText: S.of(context).settingsPageLanguageSettingLabel,
options: [
RadioOption(
@@ -42,11 +47,11 @@ class _LanguageSelectionSettingState extends State<LanguageSelectionSetting> {
),
RadioOption(
value: 'cs',
label: _languageOptions['cs']!,
label: _languageOptions['cs']! + " *",
),
RadioOption(
value: 'tr',
label: _languageOptions['tr']!,
label: _languageOptions['tr']! + " *",
)
],
initialValue: context

View File

@@ -1,12 +1,12 @@
import 'package:bloc/bloc.dart';
import 'package:paperless_api/paperless_api.dart';
import 'package:paperless_mobile/features/paged_document_view/documents_paging_mixin.dart';
import 'package:paperless_mobile/features/paged_document_view/model/documents_paged_state.dart';
import 'package:paperless_mobile/features/paged_document_view/paged_documents_mixin.dart';
import 'package:paperless_mobile/features/paged_document_view/model/paged_documents_state.dart';
part 'similar_documents_state.dart';
class SimilarDocumentsCubit extends Cubit<SimilarDocumentsState>
with DocumentsPagingMixin<SimilarDocumentsState> {
with PagedDocumentsMixin<SimilarDocumentsState> {
final int documentId;
@override

View File

@@ -1,6 +1,6 @@
part of 'similar_documents_cubit.dart';
class SimilarDocumentsState extends DocumentsPagedState {
class SimilarDocumentsState extends PagedDocumentsState {
const SimilarDocumentsState({
super.filter,
super.hasLoaded,

View File

@@ -4,7 +4,7 @@ String formatMaxCount(int? count, [int maxCount = 99]) {
if ((count ?? 0) > maxCount) {
return "$maxCount+";
}
return (count ?? 0).toString().padLeft(maxCount.toString().length);
return (count ?? 0).toString();
}
String formatBytes(int bytes, int decimals) {