Removed suggestions from inbox, added translations, added paging to inbox, visual updates, changed default matching algorithm to auto

This commit is contained in:
Anton Stubenbord
2023-01-20 00:34:18 +01:00
parent bfbf0a6f0e
commit f9dfddf704
56 changed files with 1748 additions and 766 deletions

View File

@@ -1,6 +1,12 @@
import 'dart:developer';
import 'dart:io';
import 'package:bloc/bloc.dart';
import 'package:equatable/equatable.dart';
import 'package:paperless_api/paperless_api.dart';
import 'package:paperless_mobile/core/service/file_service.dart';
import 'package:url_launcher/url_launcher.dart';
import 'package:url_launcher/url_launcher_string.dart';
part 'document_details_state.dart';
@@ -41,6 +47,23 @@ class DocumentDetailsCubit extends Cubit<DocumentDetailsState> {
}
}
Future<bool> openDocumentInSystemViewer() async {
final downloadDir = await FileService.temporaryDirectory;
final metaData = await _api.getMetaData(state.document);
final docBytes = await _api.download(state.document);
File f = File('${downloadDir.path}/${metaData.mediaFilename}');
f.writeAsBytes(docBytes);
final url = Uri(
scheme: "file",
path: f.path,
);
log(url.toString());
if (await canLaunchUrl(url)) {
return launchUrl(url);
}
return false;
}
void replaceDocument(DocumentModel document) {
emit(state.copyWith(document: document));
}

View File

@@ -8,6 +8,7 @@ import 'package:intl/intl.dart';
import 'package:paperless_api/paperless_api.dart';
import 'package:paperless_mobile/core/bloc/connectivity_cubit.dart';
import 'package:paperless_mobile/core/repository/state/impl/correspondent_repository_state.dart';
import 'package:paperless_mobile/core/service/file_service.dart';
import 'package:paperless_mobile/core/widgets/highlighted_text.dart';
import 'package:paperless_mobile/core/widgets/hint_card.dart';
import 'package:paperless_mobile/core/widgets/offline_widget.dart';
@@ -64,6 +65,8 @@ class _DocumentDetailsPageState extends State<DocumentDetailsPage> {
floatingActionButton: widget.allowEdit
? BlocBuilder<DocumentDetailsCubit, DocumentDetailsState>(
builder: (context, state) {
final _filteredSuggestions =
state.suggestions.documentDifference(state.document);
return BlocBuilder<ConnectivityCubit, ConnectivityState>(
builder: (context, connectivityState) {
if (!connectivityState.isConnected) {
@@ -71,13 +74,13 @@ class _DocumentDetailsPageState extends State<DocumentDetailsPage> {
}
return b.Badge(
position: b.BadgePosition.topEnd(top: -12, end: -6),
showBadge: state.suggestions.hasSuggestions,
showBadge: _filteredSuggestions.hasSuggestions,
child: FloatingActionButton(
child: const Icon(Icons.edit),
onPressed: () => _onEdit(state.document),
),
badgeContent: Text(
'${state.suggestions.suggestionsCount}',
'${_filteredSuggestions.suggestionsCount}',
style: const TextStyle(
color: Colors.white,
),
@@ -106,16 +109,27 @@ class _DocumentDetailsPageState extends State<DocumentDetailsPage> {
? () => _onDelete(state.document)
: null,
).paddedSymmetrically(horizontal: 4),
DocumentDownloadButton(
document: state.document,
enabled: isConnected,
Tooltip(
message: "Download",
child: DocumentDownloadButton(
document: state.document,
enabled: isConnected,
),
),
IconButton(
icon: const Icon(Icons.open_in_new),
icon: const Icon(Icons.visibility),
onPressed: isConnected
? () => _onOpen(state.document)
: null,
).paddedOnly(right: 4.0),
// IconButton(
// icon: const Icon(Icons.open_in_new),
// onPressed: isConnected
// ? context
// .read<DocumentDetailsCubit>()
// .openDocumentInSystemViewer
// : null,
// ).paddedOnly(right: 4.0),
IconButton(
icon: const Icon(Icons.share),
onPressed: isConnected

View File

@@ -153,12 +153,11 @@ class _DocumentUploadPreparationPageState
S
.of(context)
.documentUploadPageSynchronizeTitleAndFilenameLabel,
), //TODO: INTL
),
),
FormBuilderDateTimePicker(
enabled: false,
autovalidateMode: AutovalidateMode.always,
format: DateFormat("dd. MMMM yyyy"), //TODO: INTL
format: DateFormat.yMMMMd(),
inputType: InputType.date,
name: DocumentModel.createdKey,
initialValue: null,
@@ -168,11 +167,6 @@ class _DocumentUploadPreparationPageState
S.of(context).documentCreatedPropertyLabel + " *",
),
),
const HintCard(
hintText:
"Due to an apparent parsing bug with Paperless, setting the 'created at' date will cause the document consumption to fail! Therefore this field is disabled for now until this is fixed or I find a workaround!",
show: true,
),
LabelFormField<DocumentType>(
notAssignedSelectable: false,
formBuilderState: _formKey.currentState,

View File

@@ -5,19 +5,21 @@ 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';
class DocumentsCubit extends HydratedCubit<DocumentsState>
with DocumentsPagingMixin {
@override
final PaperlessDocumentsApi api;
class DocumentsCubit extends Cubit<DocumentsState> with HydratedMixin {
final PaperlessDocumentsApi _api;
final SavedViewRepository _savedViewRepository;
DocumentsCubit(this._api, this._savedViewRepository)
: super(const DocumentsState()) {
hydrate();
}
DocumentsCubit(this.api, this._savedViewRepository)
: super(const DocumentsState());
Future<void> bulkRemove(List<DocumentModel> documents) async {
log("[DocumentsCubit] bulkRemove");
await _api.bulkAction(
await api.bulkAction(
BulkDeleteAction(documents.map((doc) => doc.id)),
);
await reload();
@@ -29,7 +31,7 @@ class DocumentsCubit extends Cubit<DocumentsState> with HydratedMixin {
Iterable<int> removeTags = const [],
}) async {
log("[DocumentsCubit] bulkEditTags");
await _api.bulkAction(BulkModifyTagsAction(
await api.bulkAction(BulkModifyTagsAction(
documents.map((doc) => doc.id),
addTags: addTags,
removeTags: removeTags,
@@ -37,132 +39,6 @@ class DocumentsCubit extends Cubit<DocumentsState> with HydratedMixin {
await reload();
}
Future<void> update(
DocumentModel document, [
bool updateRemote = true,
]) async {
log("[DocumentsCubit] update");
if (updateRemote) {
await _api.update(document);
}
await reload();
}
Future<void> load() async {
log("[DocumentsCubit] load");
emit(state.copyWith(isLoading: true));
try {
final result = await _api.findAll(state.filter);
emit(state.copyWith(
isLoading: false,
hasLoaded: true,
value: [...state.value, result],
));
} finally {
emit(state.copyWith(isLoading: false));
}
}
Future<void> reload() async {
log("[DocumentsCubit] reload");
emit(state.copyWith(isLoading: true));
try {
final filter = state.filter.copyWith(page: 1);
final result = await _api.findAll(filter);
emit(state.copyWith(
hasLoaded: true,
value: [result],
isLoading: false,
filter: filter,
));
} finally {
emit(state.copyWith(isLoading: false));
}
}
Future<void> _bulkReloadDocuments() async {
emit(state.copyWith(isLoading: true));
try {
final result = await _api.findAll(
state.filter.copyWith(
page: 1,
pageSize: state.documents.length,
),
);
emit(DocumentsState(
hasLoaded: true,
value: [result],
filter: state.filter,
isLoading: false,
));
} finally {
emit(state.copyWith(isLoading: false));
}
}
Future<void> loadMore() async {
log("[DocumentsCubit] loadMore");
if (state.isLastPageLoaded) {
return;
}
emit(state.copyWith(isLoading: true));
final newFilter = state.filter.copyWith(page: state.filter.page + 1);
try {
final result = await _api.findAll(newFilter);
emit(
DocumentsState(
hasLoaded: true,
value: [...state.value, result],
filter: newFilter,
isLoading: false,
),
);
} finally {
emit(state.copyWith(isLoading: false));
}
}
///
/// Updates document filter and automatically reloads documents. Always resets page to 1.
/// Use [loadMore] to load more data.
Future<void> updateFilter({
final DocumentFilter filter = DocumentFilter.initial,
}) async {
log("[DocumentsCubit] updateFilter");
try {
emit(state.copyWith(isLoading: true));
final result = await _api.findAll(filter.copyWith(page: 1));
emit(
DocumentsState(
filter: filter,
value: [result],
hasLoaded: true,
isLoading: false,
),
);
} finally {
emit(state.copyWith(isLoading: false));
}
}
Future<void> resetFilter() {
log("[DocumentsCubit] resetFilter");
final filter = DocumentFilter.initial.copyWith(
sortField: state.filter.sortField,
sortOrder: state.filter.sortOrder,
);
return updateFilter(filter: filter);
}
///
/// Convenience method which allows to directly use [DocumentFilter.copyWith] on the current filter.
///
Future<void> updateCurrentFilter(
final DocumentFilter Function(DocumentFilter) transformFn,
) async =>
updateFilter(filter: transformFn(state.filter));
void toggleDocumentSelection(DocumentModel model) {
log("[DocumentsCubit] toggleSelection");
if (state.selectedIds.contains(model.id)) {
@@ -185,12 +61,6 @@ class DocumentsCubit extends Cubit<DocumentsState> with HydratedMixin {
emit(state.copyWith(selection: []));
}
@override
void onChange(Change<DocumentsState> change) {
super.onChange(change);
log("[DocumentsCubit] state changed: ${change.currentState.selection.map((e) => e.id).join(",")} to ${change.nextState.selection.map((e) => e.id).join(",")}");
}
void reset() {
log("[DocumentsCubit] reset");
emit(const DocumentsState());
@@ -204,7 +74,7 @@ class DocumentsCubit extends Cubit<DocumentsState> with HydratedMixin {
if (filter == null) {
return;
}
final results = await _api.findAll(filter.copyWith(page: 1));
final results = await api.findAll(filter.copyWith(page: 1));
emit(
DocumentsState(
filter: filter,
@@ -220,7 +90,7 @@ class DocumentsCubit extends Cubit<DocumentsState> with HydratedMixin {
}
Future<Iterable<String>> autocomplete(String query) async {
final res = await _api.autocomplete(query);
final res = await api.autocomplete(query);
return res;
}

View File

@@ -1,72 +1,25 @@
import 'dart:developer';
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';
class DocumentsState extends Equatable {
final bool isLoading;
final bool hasLoaded;
final DocumentFilter filter;
final List<PagedSearchResult<DocumentModel>> value;
class DocumentsState extends DocumentsPagedState {
final int? selectedSavedViewId;
@JsonKey(ignore: true)
final List<DocumentModel> selection;
const DocumentsState({
this.hasLoaded = false,
this.isLoading = false,
this.value = const [],
this.filter = const DocumentFilter(),
this.selection = const [],
this.selectedSavedViewId,
super.value = const [],
super.filter = const DocumentFilter(),
super.hasLoaded = false,
super.isLoading = false,
});
List<int> get selectedIds => selection.map((e) => e.id).toList();
int get currentPageNumber {
return filter.page;
}
int? get nextPageNumber {
return isLastPageLoaded ? null : currentPageNumber + 1;
}
int get count {
if (value.isEmpty) {
return 0;
}
return value.first.count;
}
bool get isLastPageLoaded {
if (!hasLoaded) {
return false;
}
if (value.isNotEmpty) {
return value.last.next == null;
}
return true;
}
int inferPageCount({required int pageSize}) {
if (!hasLoaded) {
return 100000;
}
if (value.isEmpty) {
return 0;
}
return value.first.inferPageCount(pageSize: pageSize);
}
List<DocumentModel> get documents {
return value.fold(
[], (previousValue, element) => [...previousValue, ...element.results]);
}
DocumentsState copyWith({
bool overwrite = false,
bool? hasLoaded,
bool? isLoading,
List<PagedSearchResult<DocumentModel>>? value,
@@ -118,4 +71,19 @@ class DocumentsState extends Equatable {
filter: DocumentFilter.fromJson(json['filter']),
);
}
@override
DocumentsState copyWithPaged({
bool? hasLoaded,
bool? isLoading,
List<PagedSearchResult<DocumentModel>>? value,
DocumentFilter? filter,
}) {
return copyWith(
filter: filter,
hasLoaded: hasLoaded,
isLoading: isLoading,
value: value,
);
}
}

View File

@@ -16,8 +16,6 @@ import 'package:paperless_mobile/features/edit_document/cubit/edit_document_cubi
import 'package:paperless_mobile/features/edit_label/view/impl/add_correspondent_page.dart';
import 'package:paperless_mobile/features/edit_label/view/impl/add_document_type_page.dart';
import 'package:paperless_mobile/features/edit_label/view/impl/add_storage_path_page.dart';
import 'package:paperless_mobile/features/labels/bloc/label_cubit.dart';
import 'package:paperless_mobile/features/labels/bloc/label_state.dart';
import 'package:paperless_mobile/features/labels/tags/view/widgets/tags_form_field.dart';
import 'package:paperless_mobile/features/labels/view/widgets/label_form_field.dart';
import 'package:paperless_mobile/generated/l10n.dart';
@@ -45,6 +43,15 @@ class _DocumentEditPageState extends State<DocumentEditPage> {
final GlobalKey<FormBuilderState> _formKey = GlobalKey();
bool _isSubmitLoading = false;
late final FieldSuggestions _filteredSuggestions;
@override
void initState() {
super.initState();
_filteredSuggestions = widget.suggestions
.documentDifference(context.read<EditDocumentCubit>().state.document);
}
@override
Widget build(BuildContext context) {
return BlocBuilder<EditDocumentCubit, EditDocumentState>(
@@ -99,25 +106,35 @@ class _DocumentEditPageState extends State<DocumentEditPage> {
excludeAllowed: false,
name: fkTags,
selectableOptions: state.tags,
suggestions: widget.suggestions.hasSuggestedTags
suggestions: _filteredSuggestions.tags
.toSet()
.difference(state.document.tags.toSet())
.isNotEmpty
? _buildSuggestionsSkeleton<int>(
suggestions: widget.suggestions.storagePaths,
itemBuilder: (context, itemData) => ActionChip(
label: Text(state.tags[itemData]!.name),
onPressed: () {
final currentTags = _formKey.currentState
?.fields[fkTags] as TagsQuery;
if (currentTags is IdsTagsQuery) {
_formKey.currentState?.fields[fkTags]
?.didChange((IdsTagsQuery.fromIds(
[...currentTags.ids, itemData])));
} else {
_formKey.currentState?.fields[fkTags]
?.didChange(
(IdsTagsQuery.fromIds([itemData])));
}
},
),
suggestions: _filteredSuggestions.tags,
itemBuilder: (context, itemData) {
final tag = state.tags[itemData]!;
return ActionChip(
label: Text(
tag.name,
style: TextStyle(color: tag.textColor),
),
backgroundColor: tag.color,
onPressed: () {
final currentTags = _formKey.currentState
?.fields[fkTags]?.value as TagsQuery;
if (currentTags is IdsTagsQuery) {
_formKey.currentState?.fields[fkTags]
?.didChange((IdsTagsQuery.fromIds(
{...currentTags.ids, itemData})));
} else {
_formKey.currentState?.fields[fkTags]
?.didChange((IdsTagsQuery.fromIds(
{itemData})));
}
},
);
},
)
: null,
).padded(),
@@ -151,15 +168,6 @@ class _DocumentEditPageState extends State<DocumentEditPage> {
name: fkStoragePath,
prefixIcon: const Icon(Icons.folder_outlined),
),
if (widget.suggestions.hasSuggestedStoragePaths)
_buildSuggestionsSkeleton<int>(
suggestions: widget.suggestions.storagePaths,
itemBuilder: (context, itemData) => ActionChip(
label: Text(options[itemData]!.name),
onPressed: () => _formKey.currentState?.fields[fkStoragePath]
?.didChange((IdQueryParameter.fromId(itemData))),
),
),
],
);
}
@@ -182,9 +190,9 @@ class _DocumentEditPageState extends State<DocumentEditPage> {
name: fkCorrespondent,
prefixIcon: const Icon(Icons.person_outlined),
),
if (widget.suggestions.hasSuggestedCorrespondents)
if (_filteredSuggestions.hasSuggestedCorrespondents)
_buildSuggestionsSkeleton<int>(
suggestions: widget.suggestions.correspondents,
suggestions: _filteredSuggestions.correspondents,
itemBuilder: (context, itemData) => ActionChip(
label: Text(options[itemData]!.name),
onPressed: () => _formKey.currentState?.fields[fkCorrespondent]
@@ -217,9 +225,9 @@ class _DocumentEditPageState extends State<DocumentEditPage> {
name: fkDocumentType,
prefixIcon: const Icon(Icons.description_outlined),
),
if (widget.suggestions.hasSuggestedDocumentTypes)
if (_filteredSuggestions.hasSuggestedDocumentTypes)
_buildSuggestionsSkeleton<int>(
suggestions: widget.suggestions.documentTypes,
suggestions: _filteredSuggestions.documentTypes,
itemBuilder: (context, itemData) => ActionChip(
label: Text(options[itemData]!.name),
onPressed: () => _formKey.currentState?.fields[fkDocumentType]
@@ -236,13 +244,9 @@ class _DocumentEditPageState extends State<DocumentEditPage> {
var mergedDocument = document.copyWith(
title: values[fkTitle],
created: values[fkCreatedDate],
overwriteDocumentType: true,
documentType: (values[fkDocumentType] as IdQueryParameter).id,
overwriteCorrespondent: true,
correspondent: (values[fkCorrespondent] as IdQueryParameter).id,
overwriteStoragePath: true,
storagePath: (values[fkStoragePath] as IdQueryParameter).id,
overwriteTags: true,
documentType: () => (values[fkDocumentType] as IdQueryParameter).id,
correspondent: () => (values[fkCorrespondent] as IdQueryParameter).id,
storagePath: () => (values[fkStoragePath] as IdQueryParameter).id,
tags: (values[fkTags] as IdsTagsQuery).includedIds,
);
setState(() {
@@ -288,9 +292,9 @@ class _DocumentEditPageState extends State<DocumentEditPage> {
format: DateFormat("dd. MMMM yyyy"), //TODO: Localized date format
initialEntryMode: DatePickerEntryMode.calendar,
),
if (widget.suggestions.hasSuggestedDates)
if (_filteredSuggestions.hasSuggestedDates)
_buildSuggestionsSkeleton<DateTime>(
suggestions: widget.suggestions.dates,
suggestions: _filteredSuggestions.dates,
itemBuilder: (context, itemData) => ActionChip(
label: Text(DateFormat.yMMMd().format(itemData)),
onPressed: () => _formKey.currentState?.fields[fkCreatedDate]

View File

@@ -63,13 +63,13 @@ class _DocumentsPageState extends State<DocumentsPage> {
}
_scrollController
..addListener(_listenForScrollChanges)
..addListener(_listenForLoadDataTrigger);
..addListener(_listenForLoadNewData);
}
void _listenForLoadDataTrigger() {
void _listenForLoadNewData() {
final currState = context.read<DocumentsCubit>().state;
if (_scrollController.offset >=
_scrollController.position.maxScrollExtent &&
_scrollController.position.maxScrollExtent * 0.75 &&
!currState.isLoading &&
!currState.isLastPageLoaded) {
_loadNewPage();
@@ -173,7 +173,7 @@ class _DocumentsPageState extends State<DocumentsPage> {
bottom: PreferredSize(
preferredSize: const Size.fromHeight(
linearProgressIndicatorHeight),
child: state.isLoading
child: state.isLoading && state.hasLoaded
? const LinearProgressIndicator()
: const SizedBox(height: 4.0),
),

View File

@@ -1,3 +1,5 @@
import 'dart:developer';
import 'package:dio/dio.dart';
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
@@ -108,8 +110,8 @@ class EditLabelForm<T extends Label> extends StatelessWidget {
context.read<EditLabelCubit<T>>().delete(label);
} on PaperlessServerException catch (error) {
showErrorMessage(context, error);
} catch (error) {
print(error);
} catch (error, stackTrace) {
log("An error occurred!", error: error, stackTrace: stackTrace);
}
Navigator.pop(context);
}

View File

@@ -1,8 +1,8 @@
import 'package:dio/dio.dart';
import 'package:flutter/material.dart';
import 'package:flutter_form_builder/flutter_form_builder.dart';
import 'package:form_builder_validators/form_builder_validators.dart';
import 'package:paperless_api/paperless_api.dart';
import 'package:paperless_mobile/core/translation/matching_algorithm_localization_mapper.dart';
import 'package:paperless_mobile/core/type/types.dart';
import 'package:paperless_mobile/extensions/flutter_extensions.dart';
import 'package:paperless_mobile/generated/l10n.dart';
@@ -81,10 +81,9 @@ class _LabelFormState<T extends Label> extends State<LabelForm<T>> {
onChanged: (val) => setState(() => _errors = {}),
),
FormBuilderDropdown<int?>(
//TODO: Extract to own widget.
name: Label.matchingAlgorithmKey,
initialValue: widget.initialValue?.matchingAlgorithm?.value ??
MatchingAlgorithm.allWords.value,
initialValue: widget.initialValue?.matchingAlgorithm.value ??
MatchingAlgorithm.auto.value,
decoration: InputDecoration(
labelText: S.of(context).labelMatchingAlgorithmPropertyLabel,
errorText: _errors[Label.matchingAlgorithmKey],
@@ -93,7 +92,7 @@ class _LabelFormState<T extends Label> extends State<LabelForm<T>> {
items: MatchingAlgorithm.values
.map(
(algo) => DropdownMenuItem<int?>(
child: Text(algo.name), //TODO: INTL
child: Text(translateMatchingAlgorithm(context, algo)),
value: algo.value,
),
)

View File

@@ -8,7 +8,7 @@ import 'package:paperless_api/paperless_api.dart';
import 'package:paperless_mobile/core/bloc/connectivity_cubit.dart';
import 'package:paperless_mobile/core/bloc/paperless_server_information_cubit.dart';
import 'package:paperless_mobile/core/global/constants.dart';
import 'package:paperless_mobile/core/logic/error_code_localization_mapper.dart';
import 'package:paperless_mobile/core/translation/error_code_localization_mapper.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';
@@ -50,6 +50,7 @@ class _HomePageState extends State<HomePage> {
void initState() {
super.initState();
_initializeData(context);
context.read<ConnectivityCubit>().reload();
WidgetsBinding.instance.addPostFrameCallback((timeStamp) {
_listenForReceivedFiles();
});

View File

@@ -361,7 +361,7 @@ class _InfoDrawerState extends State<InfoDrawer> {
applicationName: 'Paperless Mobile',
applicationVersion: snapshot.version + '+' + snapshot.buildNumber,
children: [
Text('${S.of(context).aboutDialogDevelopedByText} Anton Stubenbord'),
Text(S.of(context).aboutDialogDevelopedByText('Anton Stubenbord')),
Link(
uri: Uri.parse('https://github.com/astubenbord/paperless-mobile'),
builder: (context, followLink) => GestureDetector(

View File

@@ -1,7 +1,5 @@
import 'dart:async';
import 'package:collection/collection.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/repository/label_repository.dart';
@@ -9,8 +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';
class InboxCubit extends HydratedCubit<InboxState> {
class InboxCubit extends HydratedCubit<InboxState> with DocumentsPagingMixin {
final LabelRepository<Tag, TagRepositoryState> _tagsRepository;
final LabelRepository<Correspondent, CorrespondentRepositoryState>
_correspondentRepository;
@@ -21,6 +20,9 @@ class InboxCubit extends HydratedCubit<InboxState> {
final List<StreamSubscription> _subscriptions = [];
@override
PaperlessDocumentsApi get api => _documentsApi;
InboxCubit(
this._tagsRepository,
this._documentsApi,
@@ -67,105 +69,83 @@ class InboxCubit extends HydratedCubit<InboxState> {
final inboxTags = await _tagsRepository.findAll().then(
(tags) => tags.where((t) => t.isInboxTag ?? false).map((t) => t.id!),
);
if (inboxTags.isEmpty) {
// no inbox tags = no inbox items.
return emit(
state.copyWith(
isLoaded: true,
inboxItems: [],
hasLoaded: true,
value: [],
inboxTags: [],
),
);
}
final inboxDocuments = await _documentsApi
.findAll(DocumentFilter(
tags: AnyAssignedTagsQuery(tagIds: inboxTags),
sortField: SortField.added,
))
.then((psr) => psr.results);
final newState = state.copyWith(
isLoaded: true,
inboxItems: inboxDocuments,
inboxTags: inboxTags,
return updateFilter(
filter: DocumentFilter(
sortField: SortField.added,
tags: IdsTagsQuery.fromIds(inboxTags),
),
);
emit(newState);
}
///
/// Updates the document with all inbox tags removed and removes the document
/// from the currently loaded inbox documents.
/// from the inbox.
///
Future<Iterable<int>> remove(DocumentModel document) async {
Future<Iterable<int>> removeFromInbox(DocumentModel document) async {
final tagsToRemove =
document.tags.toSet().intersection(state.inboxTags.toSet());
final updatedTags = {...document.tags}..removeAll(tagsToRemove);
await _documentsApi.update(
document.copyWith(
tags: updatedTags,
overwriteTags: true,
),
await api.update(
document.copyWith(tags: updatedTags),
);
emit(
state.copyWith(
isLoaded: true,
inboxItems: state.inboxItems.where((doc) => doc.id != document.id),
),
);
await remove(document);
return tagsToRemove;
}
///
/// Adds the previously removed tags to the document and performs an update.
///
Future<void> undoRemove(
Future<void> undoRemoveFromInbox(
DocumentModel document,
Iterable<int> removedTags,
) async {
final updatedDoc = document.copyWith(
tags: {...document.tags, ...removedTags},
overwriteTags: true,
);
await _documentsApi.update(updatedDoc);
emit(state.copyWith(
isLoaded: true,
inboxItems: [...state.inboxItems, updatedDoc]
..sort((d1, d2) => d2.added.compareTo(d1.added)),
));
return reload();
}
///
/// Removes inbox tags from all documents in the inbox.
///
Future<void> clearInbox() async {
await _documentsApi.bulkAction(
BulkModifyTagsAction.removeTags(
state.inboxItems.map((e) => e.id),
state.inboxTags,
),
);
emit(state.copyWith(
isLoaded: true,
inboxItems: [],
));
emit(state.copyWith(isLoading: true));
try {
await _documentsApi.bulkAction(
BulkModifyTagsAction.removeTags(
state.documents.map((e) => e.id),
state.inboxTags,
),
);
emit(state.copyWith(
hasLoaded: true,
value: [],
));
} finally {
emit(state.copyWith(isLoading: false));
}
}
void replaceUpdatedDocument(DocumentModel document) {
if (document.tags.any((id) => state.inboxTags.contains(id))) {
// If replaced document still has inbox tag assigned:
emit(state.copyWith(
inboxItems:
state.inboxItems.map((e) => e.id == document.id ? document : e),
));
replace(document);
} else {
// Remove tag from inbox.
emit(
state.copyWith(
inboxItems:
state.inboxItems.where((element) => element.id != document.id)),
);
// Remove document from inbox.
remove(document);
}
}
@@ -174,48 +154,10 @@ class InboxCubit extends HydratedCubit<InboxState> {
final int asn = await _documentsApi.findNextAsn();
final updatedDocument = await _documentsApi
.update(document.copyWith(archiveSerialNumber: asn));
emit(
state.copyWith(
inboxItems: state.inboxItems
.map((e) => e.id == document.id ? updatedDocument : e)),
);
replace(updatedDocument);
}
}
Future<void> updateDocument(DocumentModel document) async {
final updatedDocument = await _documentsApi.update(document);
emit(
state.copyWith(
inboxItems: state.inboxItems.map(
(e) => e.id == document.id ? updatedDocument : e,
),
),
);
}
Future<void> deleteDocument(DocumentModel document) async {
int deletedId = await _documentsApi.delete(document);
emit(
state.copyWith(
inboxItems: state.inboxItems.where(
(element) => element.id != deletedId,
),
),
);
}
void loadSuggestions() {
state.inboxItems
.whereNot((doc) => state.suggestions.containsKey(doc.id))
.map((e) => _documentsApi.findSuggestions(e))
.forEach((suggestion) async {
final s = await suggestion;
emit(state.copyWith(
suggestions: {...state.suggestions, s.documentId!: s},
));
});
}
void acknowledgeHint() {
emit(state.copyWith(isHintAcknowledged: true));
}

View File

@@ -1,57 +1,56 @@
import 'package:equatable/equatable.dart';
import 'package:json_annotation/json_annotation.dart';
import 'package:paperless_api/paperless_api.dart';
import 'package:json_annotation/json_annotation.dart';
import 'package:paperless_mobile/features/paged_document_view/model/documents_paged_state.dart';
part 'inbox_state.g.dart';
@JsonSerializable(
ignoreUnannotated: true,
)
class InboxState with EquatableMixin {
final bool isLoaded;
class InboxState extends DocumentsPagedState {
final Iterable<int> inboxTags;
final Iterable<DocumentModel> inboxItems;
final Map<int, Tag> availableTags;
final Map<int, DocumentType> availableDocumentTypes;
final Map<int, Correspondent> availableCorrespondents;
final Map<int, FieldSuggestions> suggestions;
@JsonKey()
final bool isHintAcknowledged;
const InboxState({
this.isLoaded = false,
super.hasLoaded = false,
super.isLoading = false,
super.value = const [],
super.filter = const DocumentFilter(),
this.inboxTags = const [],
this.inboxItems = const [],
this.isHintAcknowledged = false,
this.availableTags = const {},
this.availableDocumentTypes = const {},
this.availableCorrespondents = const {},
this.suggestions = const {},
});
@override
List<Object?> get props => [
isLoaded,
hasLoaded,
isLoading,
value,
filter,
inboxTags,
inboxItems,
documents,
isHintAcknowledged,
availableTags,
availableDocumentTypes,
availableCorrespondents,
suggestions,
];
InboxState copyWith({
bool? isLoaded,
bool? hasLoaded,
bool? isLoading,
Iterable<int>? inboxTags,
Iterable<DocumentModel>? inboxItems,
List<PagedSearchResult<DocumentModel>>? value,
DocumentFilter? filter,
bool? isHintAcknowledged,
Map<int, Tag>? availableTags,
Map<int, Correspondent>? availableCorrespondents,
@@ -59,8 +58,9 @@ class InboxState with EquatableMixin {
Map<int, FieldSuggestions>? suggestions,
}) {
return InboxState(
isLoaded: isLoaded ?? this.isLoaded,
inboxItems: inboxItems ?? this.inboxItems,
hasLoaded: hasLoaded ?? super.hasLoaded,
isLoading: isLoading ?? super.isLoading,
value: value ?? super.value,
inboxTags: inboxTags ?? this.inboxTags,
isHintAcknowledged: isHintAcknowledged ?? this.isHintAcknowledged,
availableCorrespondents:
@@ -68,7 +68,7 @@ class InboxState with EquatableMixin {
availableDocumentTypes:
availableDocumentTypes ?? this.availableDocumentTypes,
availableTags: availableTags ?? this.availableTags,
suggestions: suggestions ?? this.suggestions,
filter: filter ?? super.filter,
);
}
@@ -76,4 +76,20 @@ class InboxState with EquatableMixin {
_$InboxStateFromJson(json);
Map<String, dynamic> toJson() => _$InboxStateToJson(this);
@override
InboxState copyWithPaged({
bool? hasLoaded,
bool? isLoading,
List<PagedSearchResult<DocumentModel>>? value,
DocumentFilter?
filter, // Ignored as filter does not change while inbox is open
}) {
return copyWith(
hasLoaded: hasLoaded,
isLoading: isLoading,
value: value,
filter: filter,
);
}
}

View File

@@ -23,71 +23,104 @@ class InboxPage extends StatefulWidget {
}
class _InboxPageState extends State<InboxPage> {
final ScrollController _scrollController = ScrollController();
final _emptyStateRefreshIndicatorKey = GlobalKey<RefreshIndicatorState>();
@override
void initState() {
super.initState();
initializeDateFormatting();
_scrollController.addListener(_listenForLoadNewData);
}
@override
void dispose() {
_scrollController.removeListener(_listenForLoadNewData);
super.dispose();
}
void _listenForLoadNewData() {
final currState = context.read<InboxCubit>().state;
if (_scrollController.offset >=
_scrollController.position.maxScrollExtent * 0.75 &&
!currState.isLoading &&
!currState.isLastPageLoaded) {
try {
context.read<InboxCubit>().loadMore();
} on PaperlessServerException catch (error, stackTrace) {
showErrorMessage(context, error, stackTrace);
}
}
}
@override
Widget build(BuildContext context) {
const _progressBarHeight = 4.0;
return Scaffold(
appBar: AppBar(
title: Text(S.of(context).bottomNavInboxPageLabel),
leading: IconButton(
icon: const Icon(Icons.close),
onPressed: () => Navigator.pop(context),
appBar: PreferredSize(
preferredSize:
const Size.fromHeight(kToolbarHeight + _progressBarHeight),
child: BlocBuilder<InboxCubit, InboxState>(
builder: (context, state) {
return AppBar(
title: Text(S.of(context).bottomNavInboxPageLabel),
leading: IconButton(
icon: const Icon(Icons.close),
onPressed: () => Navigator.pop(context),
),
actions: [
if (state.hasLoaded)
Align(
alignment: Alignment.centerRight,
child: ClipRRect(
borderRadius: BorderRadius.circular(8.0),
child: ColoredBox(
color: Theme.of(context).colorScheme.secondaryContainer,
child: Text(
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),
),
),
).paddedSymmetrically(horizontal: 8)
],
bottom: PreferredSize(
preferredSize: const Size.fromHeight(4),
child: state.isLoading && state.hasLoaded
? const LinearProgressIndicator()
: const SizedBox(height: _progressBarHeight),
),
);
},
),
actions: [
BlocBuilder<InboxCubit, InboxState>(
builder: (context, state) {
return Align(
alignment: Alignment.centerRight,
child: ClipRRect(
borderRadius: BorderRadius.circular(8.0),
child: ColoredBox(
color: Theme.of(context).colorScheme.secondaryContainer,
child: Text(
'${state.inboxItems.length} ${S.of(context).inboxPageUnseenText}',
textAlign: TextAlign.start,
style: Theme.of(context).textTheme.bodySmall,
).paddedSymmetrically(horizontal: 4.0),
),
),
);
},
).paddedSymmetrically(horizontal: 8)
],
),
floatingActionButton: BlocBuilder<InboxCubit, InboxState>(
builder: (context, state) {
if (!state.isLoaded || state.inboxItems.isEmpty) {
if (!state.hasLoaded || state.documents.isEmpty) {
return const SizedBox.shrink();
}
return FloatingActionButton.extended(
label: Text(S.of(context).inboxPageMarkAllAsSeenLabel),
icon: const Icon(Icons.done_all),
onPressed: state.isLoaded && state.inboxItems.isNotEmpty
onPressed: state.hasLoaded && state.documents.isNotEmpty
? () => _onMarkAllAsSeen(
state.inboxItems,
state.documents,
state.inboxTags,
)
: null,
);
},
),
body: BlocConsumer<InboxCubit, InboxState>(
listenWhen: (previous, current) =>
!previous.isLoaded && current.isLoaded,
listener: (context, state) =>
context.read<InboxCubit>().loadSuggestions(),
body: BlocBuilder<InboxCubit, InboxState>(
builder: (context, state) {
if (!state.isLoaded) {
if (!state.hasLoaded) {
return const DocumentsListLoadingWidget();
}
if (state.inboxItems.isEmpty) {
if (state.documents.isEmpty) {
return InboxEmptyWidget(
emptyStateRefreshIndicatorKey: _emptyStateRefreshIndicatorKey,
);
@@ -95,7 +128,7 @@ class _InboxPageState extends State<InboxPage> {
// Build a list of slivers alternating between SliverToBoxAdapter
// (group header) and a SliverList (inbox items).
final List<Widget> slivers = _groupByDate(state.inboxItems)
final List<Widget> slivers = _groupByDate(state.documents)
.entries
.map(
(entry) => [
@@ -148,6 +181,7 @@ class _InboxPageState extends State<InboxPage> {
children: [
Expanded(
child: CustomScrollView(
controller: _scrollController,
slivers: [
SliverToBoxAdapter(
child: HintCard(
@@ -157,7 +191,7 @@ class _InboxPageState extends State<InboxPage> {
context.read<InboxCubit>().acknowledgeHint(),
),
),
...slivers
...slivers,
],
),
),
@@ -234,7 +268,7 @@ class _InboxPageState extends State<InboxPage> {
Future<bool> _onItemDismissed(DocumentModel doc) async {
try {
final removedTags = await context.read<InboxCubit>().remove(doc);
final removedTags = await context.read<InboxCubit>().removeFromInbox(doc);
showSnackBar(
context,
S.of(context).inboxPageDocumentRemovedMessageText,
@@ -261,7 +295,9 @@ class _InboxPageState extends State<InboxPage> {
Iterable<int> removedTags,
) async {
try {
await context.read<InboxCubit>().undoRemove(document, removedTags);
await context
.read<InboxCubit>()
.undoRemoveFromInbox(document, removedTags);
} on PaperlessServerException catch (error, stackTrace) {
showErrorMessage(context, error, stackTrace);
}

View File

@@ -1,25 +1,18 @@
import 'dart:developer';
import 'package:collection/collection.dart';
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:intl/intl.dart';
import 'package:paperless_api/paperless_api.dart';
import 'package:paperless_mobile/core/repository/provider/label_repositories_provider.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/extensions/date_time_extensions.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/documents/view/widgets/delete_document_confirmation_dialog.dart';
import 'package:paperless_mobile/features/documents/view/widgets/document_preview.dart';
import 'package:paperless_mobile/features/inbox/bloc/inbox_cubit.dart';
import 'package:paperless_mobile/features/inbox/bloc/state/inbox_state.dart';
import 'package:paperless_mobile/features/labels/tags/view/widgets/tags_widget.dart';
import 'package:paperless_mobile/features/labels/view/widgets/label_text.dart';
import 'package:paperless_mobile/generated/l10n.dart';
import 'package:paperless_mobile/util.dart';
class InboxItem extends StatefulWidget {
static const _a4AspectRatio = 1 / 1.4142;
@@ -37,6 +30,8 @@ class InboxItem extends StatefulWidget {
}
class _InboxItemState extends State<InboxItem> {
// late final Future<FieldSuggestions> _fieldSuggestions;
bool _isAsnAssignLoading = false;
@override
@@ -65,7 +60,7 @@ class _InboxItemState extends State<InboxItem> {
}
},
child: SizedBox(
height: 180,
height: 200,
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
@@ -128,54 +123,66 @@ class _InboxItemState extends State<InboxItem> {
) ??
false;
if (shouldDelete) {
context.read<InboxCubit>().deleteDocument(widget.document);
context.read<InboxCubit>().delete(widget.document);
}
},
),
];
return BlocBuilder<InboxCubit, InboxState>(
builder: (context, state) {
return Row(
// return FutureBuilder<FieldSuggestions>(
// future: _fieldSuggestions,
// builder: (context, snapshot) {
// List<Widget>? suggestions;
// if (!snapshot.hasData) {
// suggestions = [
// const SizedBox(width: 4),
// ];
// } else {
// if (snapshot.data!.hasSuggestions) {
// suggestions = [
// const SizedBox(width: 4),
// ..._buildSuggestionChips(
// chipShape,
// snapshot.data!,
// context.watch<InboxCubit>().state,
// ),
// ];
// }
// }
return Row(
children: [
Row(
mainAxisSize: MainAxisSize.min,
children: [
Row(
mainAxisSize: MainAxisSize.min,
children: [
const Icon(Icons.bolt_outlined),
SizedBox(
width: 40,
child: Text(
S.of(context).inboxPageQuickActionsLabel,
textAlign: TextAlign.center,
maxLines: 2,
style: Theme.of(context).textTheme.labelSmall,
),
),
const VerticalDivider(
indent: 16,
endIndent: 16,
),
],
),
const SizedBox(width: 4.0),
Expanded(
child: ListView(
scrollDirection: Axis.horizontal,
children: [
...actions,
if (state.suggestions[widget.document.id] != null) ...[
const SizedBox(width: 4),
..._buildSuggestionChips(
chipShape,
state.suggestions[widget.document.id]!,
state,
)
]
],
const Icon(Icons.bolt_outlined),
SizedBox(
width: 40,
child: Text(
S.of(context).inboxPageQuickActionsLabel,
textAlign: TextAlign.center,
maxLines: 2,
style: Theme.of(context).textTheme.labelSmall,
),
),
const VerticalDivider(
indent: 16,
endIndent: 16,
),
],
);
},
),
const SizedBox(width: 4.0),
Expanded(
child: ListView(
scrollDirection: Axis.horizontal,
children: [
...actions,
// if (suggestions != null) ...suggestions,
],
),
),
],
// );
// },
);
}
@@ -274,97 +281,103 @@ class _InboxItemState extends State<InboxItem> {
);
}
List<Widget> _buildSuggestionChips(
OutlinedBorder chipShape,
FieldSuggestions suggestions,
InboxState state,
) {
return [
...suggestions.correspondents
.whereNot((e) => widget.document.correspondent == e)
.map(
(e) => ActionChip(
avatar: const Icon(Icons.person_outline),
shape: chipShape,
label: Text(state.availableCorrespondents[e]?.name ?? ''),
onPressed: () {
context
.read<InboxCubit>()
.updateDocument(widget.document.copyWith(
correspondent: e,
overwriteCorrespondent: true,
))
.then((value) => showSnackBar(
context,
S
.of(context)
.inboxPageSuggestionSuccessfullyAppliedMessage));
},
),
)
.toList(),
...suggestions.documentTypes
.whereNot((e) => widget.document.documentType == e)
.map(
(e) => ActionChip(
avatar: const Icon(Icons.description_outlined),
shape: chipShape,
label: Text(state.availableDocumentTypes[e]?.name ?? ''),
onPressed: () => context
.read<InboxCubit>()
.updateDocument(widget.document
.copyWith(documentType: e, overwriteDocumentType: true))
.then((value) => showSnackBar(
context,
S
.of(context)
.inboxPageSuggestionSuccessfullyAppliedMessage)),
),
)
.toList(),
...suggestions.tags
.whereNot((e) => widget.document.tags.contains(e))
.map(
(e) => ActionChip(
avatar: const Icon(Icons.label_outline),
shape: chipShape,
label: Text(state.availableTags[e]?.name ?? ''),
onPressed: () {
context
.read<InboxCubit>()
.updateDocument(widget.document.copyWith(
tags: {...widget.document.tags, e}.toList(),
overwriteTags: true,
))
.then((value) => showSnackBar(
context,
S
.of(context)
.inboxPageSuggestionSuccessfullyAppliedMessage));
},
),
)
.toList(),
...suggestions.dates
.whereNot((e) => widget.document.created.isEqualToIgnoringDate(e))
.map(
(e) => ActionChip(
avatar: const Icon(Icons.calendar_today_outlined),
shape: chipShape,
label: Text(
"${S.of(context).documentCreatedPropertyLabel}: ${DateFormat.yMd().format(e)}",
),
onPressed: () => context
.read<InboxCubit>()
.updateDocument(widget.document.copyWith(created: e))
.then((value) => showSnackBar(
context,
S
.of(context)
.inboxPageSuggestionSuccessfullyAppliedMessage)),
),
)
.toList(),
].expand((element) => [element, const SizedBox(width: 4)]).toList();
}
// List<Widget> _buildSuggestionChips(
// OutlinedBorder chipShape,
// FieldSuggestions suggestions,
// InboxState state,
// ) {
// return [
// ...suggestions.correspondents
// .whereNot((e) => widget.document.correspondent == e)
// .map(
// (e) => ActionChip(
// avatar: const Icon(Icons.person_outline),
// shape: chipShape,
// label: Text(state.availableCorrespondents[e]?.name ?? ''),
// onPressed: () {
// context
// .read<InboxCubit>()
// .update(
// widget.document.copyWith(correspondent: () => e),
// )
// .then((value) => showSnackBar(
// context,
// S
// .of(context)
// .inboxPageSuggestionSuccessfullyAppliedMessage));
// },
// ),
// )
// .toList(),
// ...suggestions.documentTypes
// .whereNot((e) => widget.document.documentType == e)
// .map(
// (e) => ActionChip(
// avatar: const Icon(Icons.description_outlined),
// shape: chipShape,
// label: Text(state.availableDocumentTypes[e]?.name ?? ''),
// onPressed: () => context
// .read<InboxCubit>()
// .update(
// widget.document.copyWith(documentType: () => e),
// shouldReload: false,
// )
// .then((value) => showSnackBar(
// context,
// S
// .of(context)
// .inboxPageSuggestionSuccessfullyAppliedMessage)),
// ),
// )
// .toList(),
// ...suggestions.tags
// .whereNot((e) => widget.document.tags.contains(e))
// .map(
// (e) => ActionChip(
// avatar: const Icon(Icons.label_outline),
// shape: chipShape,
// label: Text(state.availableTags[e]?.name ?? ''),
// onPressed: () {
// context
// .read<InboxCubit>()
// .update(
// widget.document.copyWith(
// tags: {...widget.document.tags, e}.toList(),
// ),
// shouldReload: false,
// )
// .then((value) => showSnackBar(
// context,
// S
// .of(context)
// .inboxPageSuggestionSuccessfullyAppliedMessage));
// },
// ),
// )
// .toList(),
// ...suggestions.dates
// .whereNot((e) => widget.document.created.isEqualToIgnoringDate(e))
// .map(
// (e) => ActionChip(
// avatar: const Icon(Icons.calendar_today_outlined),
// shape: chipShape,
// label: Text(
// "${S.of(context).documentCreatedPropertyLabel}: ${DateFormat.yMd().format(e)}",
// ),
// onPressed: () => context
// .read<InboxCubit>()
// .update(
// widget.document.copyWith(created: e),
// shouldReload: false,
// )
// .then((value) => showSnackBar(
// context,
// S
// .of(context)
// .inboxPageSuggestionSuccessfullyAppliedMessage)),
// ),
// )
// .toList(),
// ].expand((element) => [element, const SizedBox(width: 4)]).toList();
// }
}

View File

@@ -25,7 +25,7 @@ class TagsWidget extends StatelessWidget {
required this.isSelectedPredicate,
this.onTagSelected,
this.showShortNames = false,
this.dense = false,
this.dense = true,
}) : super(key: key);
@override

View File

@@ -30,6 +30,7 @@ class LabelItem<T extends Label> extends StatelessWidget {
leading: leading,
onTap: () => onOpenEditPage(label),
trailing: _buildReferencedDocumentsWidget(context),
isThreeLine: true,
);
}

View File

@@ -2,6 +2,7 @@ import 'package:flutter/material.dart';
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/translation/matching_algorithm_localization_mapper.dart';
import 'package:paperless_mobile/features/labels/bloc/label_cubit.dart';
import 'package:paperless_mobile/core/widgets/offline_widget.dart';
import 'package:paperless_mobile/features/labels/bloc/label_state.dart';
@@ -70,8 +71,12 @@ class LabelTabView<T extends Label> extends StatelessWidget {
.map(
(l) => LabelItem<T>(
name: l.name,
content:
contentBuilder?.call(l) ?? Text(l.match ?? '-'),
content: contentBuilder?.call(l) ??
Text(
"${translateMatchingAlgorithm(context, l.matchingAlgorithm)}\n"
"${l.match}",
maxLines: 2,
),
onOpenEditPage: onEdit,
filterBuilder: filterBuilder,
leading: leadingBuilder?.call(l),

View File

@@ -0,0 +1,163 @@
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';
///
/// Mixin which can be used on cubits which handle documents. This implements all paging and filtering logic.
///
mixin DocumentsPagingMixin<State extends DocumentsPagedState>
on BlocBase<State> {
PaperlessDocumentsApi get api;
Future<void> loadMore() async {
if (state.isLastPageLoaded) {
return;
}
emit(state.copyWithPaged(isLoading: true));
final newFilter = state.filter.copyWith(page: state.filter.page + 1);
try {
final result = await api.findAll(newFilter);
emit(state.copyWithPaged(
hasLoaded: true,
filter: newFilter,
value: [...state.value, result],
));
} finally {
emit(state.copyWithPaged(isLoading: false));
}
}
///
/// Updates document filter and automatically reloads documents. Always resets page to 1.
/// Use [loadMore] to load more data.
Future<void> updateFilter({
final DocumentFilter filter = DocumentFilter.initial,
}) async {
try {
emit(state.copyWithPaged(isLoading: true));
final result = await api.findAll(filter.copyWith(page: 1));
emit(state.copyWithPaged(
filter: filter,
value: [result],
hasLoaded: true,
));
} finally {
emit(state.copyWithPaged(isLoading: false));
}
}
///
/// Convenience method which allows to directly use [DocumentFilter.copyWith] on the current filter.
///
Future<void> updateCurrentFilter(
final DocumentFilter Function(DocumentFilter) transformFn,
) async =>
updateFilter(filter: transformFn(state.filter));
Future<void> resetFilter() {
final filter = DocumentFilter.initial.copyWith(
sortField: state.filter.sortField,
sortOrder: state.filter.sortOrder,
);
return updateFilter(filter: filter);
}
Future<void> reload() async {
emit(state.copyWithPaged(isLoading: true));
try {
final filter = state.filter.copyWith(page: 1);
final result = await api.findAll(filter);
emit(state.copyWithPaged(
hasLoaded: true,
value: [result],
isLoading: false,
filter: filter,
));
} finally {
emit(state.copyWithPaged(isLoading: false));
}
}
///
/// Updates a document. If [shouldReload] is false, the updated document will
/// replace the currently loaded one, otherwise all documents will be reloaded.
///
Future<void> update(
DocumentModel document, {
bool shouldReload = true,
}) async {
final updatedDocument = await api.update(document);
if (shouldReload) {
await reload();
} else {
replace(updatedDocument);
}
}
///
/// Deletes a document and removes it from the currently loaded state.
///
Future<void> delete(DocumentModel document) async {
emit(state.copyWithPaged(isLoading: true));
try {
await api.delete(document);
await remove(document);
} finally {
emit(state.copyWithPaged(isLoading: false));
}
}
///
/// Removes [document] from the currently loaded state.
/// Does not delete it from the server!
///
Future<void> remove(DocumentModel document) async {
final index = state.value.indexWhere(
(page) => page.results.any((element) => element.id == document.id),
);
if (index != -1) {
final foundPage = state.value[index];
final replacementPage = foundPage.copyWith(
results: foundPage.results
..removeWhere((element) => element.id == document.id),
);
final newCount = foundPage.count - 1;
emit(
state.copyWithPaged(
value: state.value
.mapIndexed(
(currIndex, element) =>
(currIndex == index ? replacementPage : element)
.copyWith(count: newCount),
)
.toList(),
),
);
}
}
///
/// Replaces the document with the same id as [document] from the currently
/// loaded state.
///
Future<void> replace(DocumentModel document) async {
final index = state.value.indexWhere(
(page) => page.results.any((element) => element.id == document.id),
);
if (index != -1) {
final foundPage = state.value[index];
final replacementPage = foundPage.copyWith(
results: foundPage.results..replaceRange(index, index + 1, [document]),
);
emit(state.copyWithPaged(
value: state.value
.mapIndexed((currIndex, element) =>
currIndex == index ? replacementPage : element)
.toList(),
));
}
}
}

View File

@@ -0,0 +1,70 @@
import 'package:equatable/equatable.dart';
import 'package:paperless_api/paperless_api.dart';
abstract class DocumentsPagedState extends Equatable {
final bool hasLoaded;
final bool isLoading;
final List<PagedSearchResult<DocumentModel>> value;
final DocumentFilter filter;
const DocumentsPagedState({
this.value = const [],
this.hasLoaded = false,
this.isLoading = false,
this.filter = const DocumentFilter(),
});
List<DocumentModel> get documents {
return value.fold(
[],
(previousValue, element) => [
...previousValue,
...element.results,
],
);
}
int get currentPageNumber {
assert(value.isNotEmpty);
return value.last.pageKey;
}
int? get nextPageNumber {
return isLastPageLoaded ? null : currentPageNumber + 1;
}
int get count {
if (value.isEmpty) {
return 0;
}
return value.first.count;
}
bool get isLastPageLoaded {
if (!hasLoaded) {
return false;
}
if (value.isNotEmpty) {
return value.last.next == null;
}
return true;
}
int inferPageCount({required int pageSize}) {
if (!hasLoaded) {
return 100000;
}
if (value.isEmpty) {
return 0;
}
return value.first.inferPageCount(pageSize: pageSize);
}
// Return type has to be dynamic
dynamic copyWithPaged({
bool? hasLoaded,
bool? isLoading,
List<PagedSearchResult<DocumentModel>>? value,
DocumentFilter? filter,
});
}

View File

@@ -18,6 +18,7 @@ class _LanguageSelectionSettingState extends State<LanguageSelectionSetting> {
'en': 'English',
'de': 'Deutsch',
'cs': 'Česky',
'tr': 'Türkçe',
};
@override
Widget build(BuildContext context) {
@@ -42,6 +43,10 @@ class _LanguageSelectionSettingState extends State<LanguageSelectionSetting> {
RadioOption(
value: 'cs',
label: _languageOptions['cs']!,
),
RadioOption(
value: 'tr',
label: _languageOptions['tr']!,
)
],
initialValue: context