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_api/paperless_api.dart';
import 'package:paperless_mobile/generated/l10n.dart'; import 'package:paperless_mobile/generated/l10n.dart';
String translateSortField(BuildContext context, SortField sortField) { String translateSortField(BuildContext context, SortField? sortField) {
switch (sortField) { switch (sortField) {
case SortField.archiveSerialNumber: case SortField.archiveSerialNumber:
return S.of(context).documentArchiveSerialNumberPropertyShortLabel; return S.of(context).documentArchiveSerialNumberPropertyShortLabel;
@@ -18,5 +18,7 @@ String translateSortField(BuildContext context, SortField sortField) {
return S.of(context).documentAddedPropertyLabel; return S.of(context).documentAddedPropertyLabel;
case SortField.modified: case SortField.modified:
return S.of(context).documentModifiedPropertyLabel; 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,14 +28,12 @@ class SearchBar extends StatelessWidget {
final ColorScheme colorScheme = Theme.of(context).colorScheme; final ColorScheme colorScheme = Theme.of(context).colorScheme;
final TextTheme textTheme = Theme.of(context).textTheme; final TextTheme textTheme = Theme.of(context).textTheme;
return Padding( return Container(
padding: const EdgeInsets.symmetric(horizontal: 16),
child: Container(
constraints: const BoxConstraints(minWidth: 360, maxWidth: 720), constraints: const BoxConstraints(minWidth: 360, maxWidth: 720),
width: double.infinity, width: double.infinity,
height: effectiveHeight, height: effectiveHeight,
child: Material( child: Material(
elevation: 3, elevation: 1,
color: colorScheme.surface, color: colorScheme.surface,
shadowColor: colorScheme.shadow, shadowColor: colorScheme.shadow,
surfaceTintColor: colorScheme.surfaceTint, surfaceTintColor: colorScheme.surfaceTint,
@@ -53,14 +51,14 @@ class SearchBar extends StatelessWidget {
child: Padding( child: Padding(
padding: const EdgeInsets.only(right: 8), padding: const EdgeInsets.only(right: 8),
child: TextField( child: TextField(
readOnly: true,
cursorColor: colorScheme.primary, cursorColor: colorScheme.primary,
style: textTheme.bodyLarge, style: textTheme.bodyLarge,
textAlignVertical: TextAlignVertical.center, textAlignVertical: TextAlignVertical.center,
decoration: InputDecoration( decoration: InputDecoration(
isCollapsed: true, isCollapsed: true,
border: InputBorder.none, border: InputBorder.none,
contentPadding: contentPadding: const EdgeInsets.symmetric(horizontal: 8),
const EdgeInsets.symmetric(horizontal: 8),
hintText: supportingText, hintText: supportingText,
hintStyle: textTheme.bodyLarge?.apply( hintStyle: textTheme.bodyLarge?.apply(
color: colorScheme.onSurfaceVariant, color: colorScheme.onSurfaceVariant,
@@ -75,7 +73,6 @@ class SearchBar extends StatelessWidget {
), ),
), ),
), ),
),
); );
} }
} }

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_api/paperless_api.dart';
import 'package:paperless_mobile/core/repository/saved_view_repository.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/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> class DocumentsCubit extends HydratedCubit<DocumentsState>
with DocumentsPagingMixin { with PagedDocumentsMixin {
@override @override
final PaperlessDocumentsApi api; final PaperlessDocumentsApi api;

View File

@@ -1,8 +1,8 @@
import 'package:json_annotation/json_annotation.dart'; import 'package:json_annotation/json_annotation.dart';
import 'package:paperless_api/paperless_api.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; final int? selectedSavedViewId;
@JsonKey(ignore: true) @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_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/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/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/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';
@@ -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/login/bloc/authentication_cubit.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/saved_view/view/saved_view_selection_widget.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_cubit.dart';
import 'package:paperless_mobile/features/settings/bloc/application_settings_state.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/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/generated/l10n.dart';
import 'package:paperless_mobile/helpers/format_helpers.dart'; import 'package:paperless_mobile/helpers/format_helpers.dart';
import 'package:paperless_mobile/helpers/message_helpers.dart'; import 'package:paperless_mobile/helpers/message_helpers.dart';
import 'package:paperless_mobile/constants.dart';
class DocumentFilterIntent { class DocumentFilterIntent {
final DocumentFilter? filter; final DocumentFilter? filter;
@@ -137,43 +139,163 @@ class _DocumentsPageState extends State<DocumentsPage> {
} }
}, },
builder: (context, connectivityState) { builder: (context, connectivityState) {
const linearProgressIndicatorHeight = 4.0;
return Scaffold( return Scaffold(
drawer: BlocProvider.value( // appBar: PreferredSize(
value: context.read<AuthenticationCubit>(), // preferredSize: const Size.fromHeight(
child: AppDrawer( // kToolbarHeight,
afterInboxClosed: () => context.read<DocumentsCubit>().reload(), // ),
// 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: 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"),
),
),
)
];
},
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,
// ),
// );
// },
// ),
// ),
// ],
// );
},
), ),
), ),
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>(
BlocBuilder<ApplicationSettingsCubit, ApplicationSettingsState>
_buildViewTypeButton() {
return BlocBuilder<ApplicationSettingsCubit, ApplicationSettingsState>(
builder: (context, settingsState) => IconButton( builder: (context, settingsState) => IconButton(
icon: Icon( icon: Icon(
settingsState.preferredViewType == ViewType.grid settingsState.preferredViewType == ViewType.grid
@@ -186,97 +308,8 @@ class _DocumentsPageState extends State<DocumentsPage> {
_offset = 0; _offset = 0;
_last = 0; _last = 0;
}); });
final cubit = final cubit = context.read<ApplicationSettingsCubit>();
context.read<ApplicationSettingsCubit>(); cubit.setViewType(cubit.state.preferredViewType.toggle());
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,
),
);
},
),
resizeToAvoidBottomInset: true,
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 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,
),
);
},
),
),
],
);
},
),
),
),
);
}, },
), ),
); );
@@ -392,7 +425,26 @@ class _DocumentsPageState extends State<DocumentsPage> {
onDocumentTypeSelected: _addDocumentTypeToFilter, onDocumentTypeSelected: _addDocumentTypeToFilter,
onStoragePathSelected: _addStoragePathToFilter, onStoragePathSelected: _addStoragePathToFilter,
pageLoadingWidget: const NewItemsLoadingWidget(), 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_api/paperless_api.dart';
import 'package:paperless_mobile/core/widgets/empty_state.dart'; import 'package:paperless_mobile/core/widgets/empty_state.dart';
import 'package:paperless_mobile/extensions/flutter_extensions.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'; import 'package:paperless_mobile/generated/l10n.dart';
class DocumentsEmptyState extends StatelessWidget { class DocumentsEmptyState extends StatelessWidget {
final DocumentsPagedState state; final PagedDocumentsState state;
final VoidCallback onReset; final VoidCallback onReset;
const DocumentsEmptyState({ const DocumentsEmptyState({
Key? key, Key? key,

View File

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

View File

@@ -4,18 +4,29 @@ import 'package:paperless_api/paperless_api.dart';
import 'package:paperless_mobile/core/repository/label_repository.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/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/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_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/search/sort_field_selection_bottom_sheet.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'; import 'package:paperless_mobile/features/labels/bloc/label_cubit.dart';
class SortDocumentsButton extends StatelessWidget { class SortDocumentsButton extends StatelessWidget {
const SortDocumentsButton({super.key}); const SortDocumentsButton({
super.key,
});
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return IconButton( return BlocBuilder<DocumentsCubit, DocumentsState>(
icon: const Icon(Icons.sort), 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: () { onPressed: () {
showModalBottomSheet( showModalBottomSheet(
elevation: 2, elevation: 2,
@@ -46,9 +57,7 @@ class SortDocumentsButton extends StatelessWidget {
), ),
), ),
], ],
child: BlocBuilder<DocumentsCubit, DocumentsState>( child: SortFieldSelectionBottomSheet(
builder: (context, state) {
return SortFieldSelectionBottomSheet(
initialSortField: state.filter.sortField, initialSortField: state.filter.sortField,
initialSortOrder: state.filter.sortOrder, initialSortOrder: state.filter.sortOrder,
onSubmit: (field, order) => onSubmit: (field, order) =>
@@ -58,13 +67,13 @@ class SortDocumentsButton extends StatelessWidget {
sortOrder: order, sortOrder: order,
), ),
), ),
);
},
), ),
), ),
), ),
); );
}, },
); );
},
);
} }
} }

View File

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

View File

@@ -317,53 +317,5 @@ class _AppDrawerState extends State<AppDrawer> {
); );
} }
Link _buildOnboardingImageCredits() { void _onShowAboutDialog() {}
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(),
],
);
}
} }

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/document_type_repository_state.dart';
import 'package:paperless_mobile/core/repository/state/impl/tag_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/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<Tag, TagRepositoryState> _tagsRepository;
final LabelRepository<Correspondent, CorrespondentRepositoryState> final LabelRepository<Correspondent, CorrespondentRepositoryState>
_correspondentRepository; _correspondentRepository;

View File

@@ -1,13 +1,13 @@
import 'package:json_annotation/json_annotation.dart'; import 'package:json_annotation/json_annotation.dart';
import 'package:paperless_api/paperless_api.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'; part 'inbox_state.g.dart';
@JsonSerializable( @JsonSerializable(
ignoreUnannotated: true, ignoreUnannotated: true,
) )
class InboxState extends DocumentsPagedState { class InboxState extends PagedDocumentsState {
final Iterable<int> inboxTags; final Iterable<int> inboxTags;
final Map<int, Tag> availableTags; final Map<int, Tag> availableTags;

View File

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

View File

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

View File

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

View File

@@ -1,13 +1,48 @@
import 'package:paperless_api/paperless_api.dart'; import 'package:paperless_api/paperless_api.dart';
import 'package:paperless_mobile/features/paged_document_view/model/paged_documents_state.dart';
class LinkedDocumentsState { class LinkedDocumentsState extends PagedDocumentsState {
final bool isLoaded; const LinkedDocumentsState({
final PagedSearchResult<DocumentModel>? documents; super.filter,
final DocumentFilter filter; super.isLoading,
super.hasLoaded,
LinkedDocumentsState({ super.value,
required this.filter,
this.isLoaded = false,
this.documents,
}); });
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/linked_documents_cubit.dart';
import 'package:paperless_mobile/features/linked_documents/bloc/state/linked_documents_state.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/generated/l10n.dart';
import 'package:paperless_mobile/helpers/message_helpers.dart';
class LinkedDocumentsPage extends StatefulWidget { class LinkedDocumentsPage extends StatefulWidget {
const LinkedDocumentsPage({super.key}); const LinkedDocumentsPage({super.key});
@@ -17,6 +18,28 @@ class LinkedDocumentsPage extends StatefulWidget {
} }
class _LinkedDocumentsPageState extends State<LinkedDocumentsPage> { 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 @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return Scaffold( return Scaffold(
@@ -25,45 +48,14 @@ class _LinkedDocumentsPageState extends State<LinkedDocumentsPage> {
), ),
body: BlocBuilder<LinkedDocumentsCubit, LinkedDocumentsState>( body: BlocBuilder<LinkedDocumentsCubit, LinkedDocumentsState>(
builder: (context, state) { builder: (context, state) {
return Column( if (!state.hasLoaded) {
children: [ return const DocumentsListLoadingWidget();
Text( }
S.of(context).referencedDocumentsReadOnlyHintText, return ListView.builder(
textAlign: TextAlign.center, itemCount: state.documents.length,
style: Theme.of(context).textTheme.bodySmall, itemBuilder: (context, index) => DocumentListItem(
document: state.documents[index],
), ),
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,
),
),
),
);
},
);
},
),
),
],
); );
}, },
), ),

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. /// Base state for all blocs/cubits using a paged view of documents.
/// [T] is the return type of the API call. /// [T] is the return type of the API call.
/// ///
abstract class DocumentsPagedState extends Equatable { abstract class PagedDocumentsState extends Equatable {
final bool hasLoaded; final bool hasLoaded;
final bool isLoading; final bool isLoading;
final List<PagedSearchResult<DocumentModel>> value; final List<PagedSearchResult<DocumentModel>> value;
final DocumentFilter filter; final DocumentFilter filter;
const DocumentsPagedState({ const PagedDocumentsState({
this.value = const [], this.value = const [],
this.hasLoaded = false, this.hasLoaded = false,
this.isLoading = false, this.isLoading = false,
@@ -71,4 +71,12 @@ abstract class DocumentsPagedState extends Equatable {
List<PagedSearchResult<DocumentModel>>? value, List<PagedSearchResult<DocumentModel>>? value,
DocumentFilter? filter, 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:flutter_bloc/flutter_bloc.dart';
import 'package:paperless_api/paperless_api.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 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> { on BlocBase<State> {
PaperlessDocumentsApi get api; PaperlessDocumentsApi get api;

View File

@@ -47,7 +47,6 @@ class _ScannerPageState extends State<ScannerPage>
return BlocBuilder<ConnectivityCubit, ConnectivityState>( return BlocBuilder<ConnectivityCubit, ConnectivityState>(
builder: (context, connectedState) { builder: (context, connectedState) {
return Scaffold( return Scaffold(
drawer: const AppDrawer(),
floatingActionButton: FloatingActionButton( floatingActionButton: FloatingActionButton(
onPressed: () => _openDocumentScanner(context), onPressed: () => _openDocumentScanner(context),
child: const Icon(Icons.add_a_photo_outlined), 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:equatable/equatable.dart';
import 'package:json_annotation/json_annotation.dart'; import 'package:json_annotation/json_annotation.dart';
import 'package:paperless_api/paperless_api.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'; part 'document_search_state.g.dart';
enum SearchView {
suggestions,
results;
}
@JsonSerializable(ignoreUnannotated: true) @JsonSerializable(ignoreUnannotated: true)
class DocumentSearchState extends DocumentsPagedState { class DocumentSearchState extends PagedDocumentsState {
@JsonKey() @JsonKey()
final List<String> searchHistory; final List<String> searchHistory;
final SearchView view;
final List<String> suggestions;
const DocumentSearchState({ const DocumentSearchState({
this.view = SearchView.suggestions,
this.searchHistory = const [], this.searchHistory = const [],
this.suggestions = const [],
super.filter, super.filter,
super.hasLoaded, super.hasLoaded,
super.isLoading, super.isLoading,
@@ -25,6 +33,8 @@ class DocumentSearchState extends DocumentsPagedState {
filter, filter,
value, value,
searchHistory, searchHistory,
suggestions,
view,
]; ];
@override @override
@@ -49,6 +59,7 @@ class DocumentSearchState extends DocumentsPagedState {
List<PagedSearchResult<DocumentModel>>? value, List<PagedSearchResult<DocumentModel>>? value,
DocumentFilter? filter, DocumentFilter? filter,
List<String>? suggestions, List<String>? suggestions,
SearchView? view,
}) { }) {
return DocumentSearchState( return DocumentSearchState(
value: value ?? this.value, value: value ?? this.value,
@@ -56,6 +67,8 @@ 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,
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:json_annotation/json_annotation.dart';
import 'package:paperless_mobile/features/settings/model/color_scheme_option.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/features/settings/model/view_type.dart';
import 'package:paperless_mobile/generated/l10n.dart';
part 'application_settings_state.g.dart'; part 'application_settings_state.g.dart';
@@ -13,7 +14,7 @@ part 'application_settings_state.g.dart';
@JsonSerializable() @JsonSerializable()
class ApplicationSettingsState { class ApplicationSettingsState {
static final defaultSettings = ApplicationSettingsState( static final defaultSettings = ApplicationSettingsState(
preferredLocaleSubtag: Platform.localeName.split('_').first, preferredLocaleSubtag: _defaultPreferredLocaleSubtag,
); );
final bool isLocalAuthenticationEnabled; final bool isLocalAuthenticationEnabled;
@@ -52,4 +53,13 @@ class ApplicationSettingsState {
preferredColorSchemeOption ?? this.preferredColorSchemeOption, 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/material.dart';
import 'package:flutter_bloc/flutter_bloc.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/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/application_settings_page.dart';
import 'package:paperless_mobile/features/settings/view/pages/security_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/features/settings/view/pages/storage_settings_page.dart';
import 'package:paperless_mobile/generated/l10n.dart'; import 'package:paperless_mobile/generated/l10n.dart';
import 'package:paperless_mobile/helpers/message_helpers.dart';
class SettingsPage extends StatelessWidget { class SettingsPage extends StatelessWidget {
const SettingsPage({super.key}); const SettingsPage({super.key});
@@ -14,22 +26,58 @@ class SettingsPage extends StatelessWidget {
return Scaffold( return Scaffold(
appBar: AppBar( appBar: AppBar(
title: Text(S.of(context).appDrawerSettingsLabel), 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( body: ListView(
children: [ children: [
ListTile( ListTile(
// leading: const Icon(Icons.style_outlined),
title: Text(S.of(context).settingsPageApplicationSettingsLabel), title: Text(S.of(context).settingsPageApplicationSettingsLabel),
subtitle: Text( subtitle: Text(
S.of(context).settingsPageApplicationSettingsDescriptionText), S.of(context).settingsPageApplicationSettingsDescriptionText),
onTap: () => _goto(const ApplicationSettingsPage(), context), onTap: () => _goto(const ApplicationSettingsPage(), context),
), ),
ListTile( ListTile(
// leading: const Icon(Icons.security_outlined),
title: Text(S.of(context).settingsPageSecuritySettingsLabel), title: Text(S.of(context).settingsPageSecuritySettingsLabel),
subtitle: subtitle:
Text(S.of(context).settingsPageSecuritySettingsDescriptionText), Text(S.of(context).settingsPageSecuritySettingsDescriptionText),
onTap: () => _goto(const SecuritySettingsPage(), context), onTap: () => _goto(const SecuritySettingsPage(), context),
), ),
ListTile( ListTile(
// leading: const Icon(Icons.storage_outlined),
title: Text(S.of(context).settingsPageStorageSettingsLabel), title: Text(S.of(context).settingsPageStorageSettingsLabel),
subtitle: subtitle:
Text(S.of(context).settingsPageStorageSettingsDescriptionText), 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/material.dart';
import 'package:flutter_bloc/flutter_bloc.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_cubit.dart';
import 'package:paperless_mobile/features/settings/bloc/application_settings_state.dart'; import 'package:paperless_mobile/features/settings/bloc/application_settings_state.dart';
import 'package:paperless_mobile/features/settings/view/widgets/radio_settings_dialog.dart'; import 'package:paperless_mobile/features/settings/view/widgets/radio_settings_dialog.dart';
@@ -20,6 +21,7 @@ class _LanguageSelectionSettingState extends State<LanguageSelectionSetting> {
'cs': 'Česky', 'cs': 'Česky',
'tr': 'Türkçe', 'tr': 'Türkçe',
}; };
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return BlocBuilder<ApplicationSettingsCubit, ApplicationSettingsState>( return BlocBuilder<ApplicationSettingsCubit, ApplicationSettingsState>(
@@ -27,9 +29,12 @@ class _LanguageSelectionSettingState extends State<LanguageSelectionSetting> {
return ListTile( return ListTile(
title: Text(S.of(context).settingsPageLanguageSettingLabel), title: Text(S.of(context).settingsPageLanguageSettingLabel),
subtitle: Text(_languageOptions[settings.preferredLocaleSubtag]!), subtitle: Text(_languageOptions[settings.preferredLocaleSubtag]!),
onTap: () => showDialog( onTap: () => showDialog<String>(
context: context, context: context,
builder: (_) => RadioSettingsDialog<String>( 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, titleText: S.of(context).settingsPageLanguageSettingLabel,
options: [ options: [
RadioOption( RadioOption(
@@ -42,11 +47,11 @@ class _LanguageSelectionSettingState extends State<LanguageSelectionSetting> {
), ),
RadioOption( RadioOption(
value: 'cs', value: 'cs',
label: _languageOptions['cs']!, label: _languageOptions['cs']! + " *",
), ),
RadioOption( RadioOption(
value: 'tr', value: 'tr',
label: _languageOptions['tr']!, label: _languageOptions['tr']! + " *",
) )
], ],
initialValue: context initialValue: context

View File

@@ -1,12 +1,12 @@
import 'package:bloc/bloc.dart'; import 'package:bloc/bloc.dart';
import 'package:paperless_api/paperless_api.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/paged_documents_mixin.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 'similar_documents_state.dart'; part 'similar_documents_state.dart';
class SimilarDocumentsCubit extends Cubit<SimilarDocumentsState> class SimilarDocumentsCubit extends Cubit<SimilarDocumentsState>
with DocumentsPagingMixin<SimilarDocumentsState> { with PagedDocumentsMixin<SimilarDocumentsState> {
final int documentId; final int documentId;
@override @override

View File

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

View File

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