Hooked notifications to status changes on document upload - some refactorings

This commit is contained in:
Anton Stubenbord
2023-01-11 01:26:36 +01:00
parent 8cf3020335
commit a4c4726c16
55 changed files with 1128 additions and 761 deletions

View File

@@ -85,7 +85,7 @@ To get a local copy up and running follow these simple steps.
```sh
flutter pub get
```
3. Build generated files (e.g. for injectable library)
3. Build generated files (for json_serializable etc.)
```sh
flutter packages pub run build_runner build --delete-conflicting-outputs
```

View File

@@ -1,10 +1,6 @@
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:paperless_mobile/core/model/document_processing_status.dart';
import 'package:injectable/injectable.dart';
@prod
@test
@lazySingleton
class DocumentStatusCubit extends Cubit<DocumentProcessingStatus?> {
DocumentStatusCubit() : super(null);

View File

@@ -1,20 +1,17 @@
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:injectable/injectable.dart';
import 'package:paperless_api/paperless_api.dart';
import 'package:paperless_mobile/core/bloc/paperless_server_information_state.dart';
import 'package:paperless_mobile/core/security/session_manager.dart';
@prod
@test
@lazySingleton
class PaperlessServerInformationCubit
extends Cubit<PaperlessServerInformationState> {
final PaperlessServerStatsApi service;
final PaperlessServerStatsApi _api;
PaperlessServerInformationCubit(this.service)
PaperlessServerInformationCubit(this._api)
: super(PaperlessServerInformationState());
Future<void> updateInformtion() async {
final information = await service.getServerInformation();
final information = await _api.getServerInformation();
emit(PaperlessServerInformationState(
isLoaded: true,
information: information,

View File

@@ -68,5 +68,9 @@ String translateError(BuildContext context, ErrorCode code) {
return S.of(context).errorMessageUnsupportedFileFormat;
case ErrorCode.missingClientCertificate:
return S.of(context).errorMessageMissingClientCertificate;
case ErrorCode.suggestionsQueryError:
return S.of(context).errorMessageSuggestionsQueryError;
case ErrorCode.acknowledgeTasksError:
return S.of(context).errorMessageAcknowledgeTasksError;
}
}

View File

@@ -2,15 +2,20 @@ import 'dart:io';
import 'package:dio/adapter.dart';
import 'package:dio/dio.dart';
import 'package:paperless_api/paperless_api.dart';
import 'package:paperless_mobile/core/interceptor/dio_http_error_interceptor.dart';
import 'package:paperless_mobile/core/interceptor/retry_on_connection_change_interceptor.dart';
import 'package:paperless_mobile/features/login/model/client_certificate.dart';
import 'package:pretty_dio_logger/pretty_dio_logger.dart';
class AuthenticationAwareDioManager {
class SessionManager {
final Dio client;
final List<Interceptor> interceptors;
PaperlessServerInformationModel serverInformation;
AuthenticationAwareDioManager([this.interceptors = const []])
: client = _initDio(interceptors);
SessionManager([this.interceptors = const []])
: client = _initDio(interceptors),
serverInformation = PaperlessServerInformationModel();
static Dio _initDio(List<Interceptor> interceptors) {
//en- and decoded by utf8 by default
@@ -19,8 +24,19 @@ class AuthenticationAwareDioManager {
dio.options.responseType = ResponseType.json;
(dio.httpClientAdapter as DefaultHttpClientAdapter).onHttpClientCreate =
(client) => client..badCertificateCallback = (cert, host, port) => true;
dio.interceptors.addAll(interceptors);
dio.interceptors.add(RetryOnConnectionChangeInterceptor(dio: dio));
dio.interceptors.addAll([
...interceptors,
DioHttpErrorInterceptor(),
PrettyDioLogger(
compact: true,
responseBody: false,
responseHeader: false,
request: false,
requestBody: false,
requestHeader: false,
),
RetryOnConnectionChangeInterceptor(dio: dio)
]);
return dio;
}
@@ -28,6 +44,7 @@ class AuthenticationAwareDioManager {
String? baseUrl,
String? authToken,
ClientCertificate? clientCertificate,
PaperlessServerInformationModel? serverInformation,
}) {
if (clientCertificate != null) {
final context = SecurityContext()
@@ -58,11 +75,16 @@ class AuthenticationAwareDioManager {
if (authToken != null) {
client.options.headers.addAll({'Authorization': 'Token $authToken'});
}
if (serverInformation != null) {
this.serverInformation = serverInformation;
}
}
void resetSettings() {
client.httpClientAdapter = DefaultHttpClientAdapter();
client.options.baseUrl = '';
client.options.headers.remove('Authorization');
serverInformation = PaperlessServerInformationModel();
}
}

View File

@@ -0,0 +1,14 @@
import 'package:hydrated_bloc/hydrated_bloc.dart';
import 'package:paperless_mobile/features/login/bloc/authentication_state.dart';
import 'package:paperless_mobile/features/settings/bloc/application_settings_cubit.dart';
import 'package:paperless_mobile/features/settings/model/application_settings_state.dart';
extension AddressableHydratedStorage on Storage {
ApplicationSettingsState get settings {
return ApplicationSettingsState.fromJson(read('ApplicationSettingsCubit'));
}
AuthenticationState get authentication {
return AuthenticationState.fromJson(read('AuthenticationCubit'));
}
}

View File

@@ -8,22 +8,40 @@ class DocumentDetailsCubit extends Cubit<DocumentDetailsState> {
final PaperlessDocumentsApi _api;
DocumentDetailsCubit(this._api, DocumentModel initialDocument)
: super(DocumentDetailsState(document: initialDocument));
: super(DocumentDetailsState(document: initialDocument)) {
loadSuggestions();
}
Future<void> delete(DocumentModel document) async {
await _api.delete(document);
}
Future<void> loadSuggestions() async {
final suggestions = await _api.findSuggestions(state.document);
emit(state.copyWith(suggestions: suggestions));
}
Future<void> loadFullContent() async {
final doc = await _api.find(state.document.id);
if (doc == null) {
return;
}
emit(state.copyWith(
isFullContentLoaded: true,
fullContent: doc.content,
));
}
Future<void> assignAsn(DocumentModel document) async {
if (document.archiveSerialNumber == null) {
final int asn = await _api.findNextAsn();
final updatedDocument =
await _api.update(document.copyWith(archiveSerialNumber: asn));
emit(DocumentDetailsState(document: updatedDocument));
emit(state.copyWith(document: updatedDocument));
}
}
void replaceDocument(DocumentModel document) {
emit(DocumentDetailsState(document: document));
emit(state.copyWith(document: document));
}
}

View File

@@ -2,11 +2,36 @@ part of 'document_details_cubit.dart';
class DocumentDetailsState with EquatableMixin {
final DocumentModel document;
final bool isFullContentLoaded;
final String? fullContent;
final FieldSuggestions suggestions;
const DocumentDetailsState({
required this.document,
this.suggestions = const FieldSuggestions(),
this.isFullContentLoaded = false,
this.fullContent,
});
@override
List<Object?> get props => [document];
List<Object?> get props => [
document,
suggestions,
isFullContentLoaded,
fullContent,
];
DocumentDetailsState copyWith({
DocumentModel? document,
FieldSuggestions? suggestions,
bool? isFullContentLoaded,
String? fullContent,
}) {
return DocumentDetailsState(
document: document ?? this.document,
suggestions: suggestions ?? this.suggestions,
isFullContentLoaded: isFullContentLoaded ?? this.isFullContentLoaded,
fullContent: fullContent ?? this.fullContent,
);
}
}

View File

@@ -25,6 +25,7 @@ import 'package:paperless_mobile/generated/l10n.dart';
import 'package:paperless_mobile/util.dart';
import 'package:path_provider/path_provider.dart';
import 'package:share_plus/share_plus.dart';
import 'package:badges/badges.dart' as b;
class DocumentDetailsPage extends StatefulWidget {
final bool allowEdit;
@@ -63,9 +64,21 @@ class _DocumentDetailsPageState extends State<DocumentDetailsPage> {
if (!connectivityState.isConnected) {
return Container();
}
return FloatingActionButton(
child: const Icon(Icons.edit),
onPressed: () => _onEdit(state.document),
return b.Badge(
position: b.BadgePosition.topEnd(top: -12, end: -6),
showBadge: state.suggestions.hasSuggestions,
child: FloatingActionButton(
child: const Icon(Icons.edit),
onPressed: () => _onEdit(state.document),
),
badgeContent: Text(
'${state.suggestions.suggestionsCount}',
style: const TextStyle(
color: Colors.white,
),
),
badgeColor: Theme.of(context).colorScheme.error,
//TODO: Wait for stable version of m3, then use AlignmentDirectional.topEnd
);
},
);
@@ -182,6 +195,7 @@ class _DocumentDetailsPageState extends State<DocumentDetailsPage> {
_buildDocumentContentView(
state.document,
widget.titleAndContentQueryString,
state,
),
_buildDocumentMetaDataView(
state.document,
@@ -217,7 +231,9 @@ class _DocumentDetailsPageState extends State<DocumentDetailsPage> {
listener: (context, state) {
cubit.replaceDocument(state.document);
},
child: const DocumentEditPage(),
child: DocumentEditPage(
suggestions: cubit.state.suggestions,
),
),
),
maintainState: true,
@@ -303,14 +319,30 @@ class _DocumentDetailsPageState extends State<DocumentDetailsPage> {
}
}
Widget _buildDocumentContentView(DocumentModel document, String? match) {
return SingleChildScrollView(
child: HighlightedText(
text: document.content ?? "",
highlights: match == null ? [] : match.split(" "),
style: Theme.of(context).textTheme.bodyMedium,
caseSensitive: false,
),
Widget _buildDocumentContentView(
DocumentModel document,
String? match,
DocumentDetailsState state,
) {
return ListView(
children: [
HighlightedText(
text: (state.isFullContentLoaded
? state.fullContent
: document.content) ??
"",
highlights: match == null ? [] : match.split(" "),
style: Theme.of(context).textTheme.bodyMedium,
caseSensitive: false,
),
if (!state.isFullContentLoaded && (document.content ?? '').isNotEmpty)
TextButton(
child: Text("Show full content ..."),
onPressed: () {
context.read<DocumentDetailsCubit>().loadFullContent();
},
),
],
).paddedOnly(top: 8);
}

View File

@@ -55,17 +55,16 @@ class DocumentUploadCubit extends Cubit<DocumentUploadState> {
));
}
Future<void> upload(
Future<String?> upload(
Uint8List bytes, {
required String filename,
required String title,
required void Function(DocumentModel document)? onConsumptionFinished,
int? documentType,
int? correspondent,
Iterable<int> tags = const [],
DateTime? createdAt,
}) async {
await _documentApi.create(
return await _documentApi.create(
bytes,
filename: filename,
title: title,
@@ -74,11 +73,6 @@ class DocumentUploadCubit extends Cubit<DocumentUploadState> {
tags: tags,
createdAt: createdAt,
);
if (onConsumptionFinished != null) {
_documentApi
.waitForConsumptionFinished(filename, title)
.then((value) => onConsumptionFinished(value));
}
}
@override

View File

@@ -25,14 +25,12 @@ class DocumentUploadPreparationPage extends StatefulWidget {
final String? title;
final String? filename;
final String? fileExtension;
final void Function(DocumentModel)? onSuccessfullyConsumed;
const DocumentUploadPreparationPage({
Key? key,
required this.fileBytes,
this.title,
this.filename,
this.onSuccessfullyConsumed,
this.fileExtension,
}) : super(key: key);
@@ -236,19 +234,18 @@ class _DocumentUploadPreparationPageState
final correspondent =
fv[DocumentModel.correspondentKey] as IdQueryParameter;
await cubit.upload(
final taskId = await cubit.upload(
widget.fileBytes,
filename:
_padWithPdfExtension(_formKey.currentState?.value[fkFileName]),
title: title,
onConsumptionFinished: widget.onSuccessfullyConsumed,
documentType: docType.id,
correspondent: correspondent.id,
tags: tags.ids,
createdAt: createdAt,
);
showSnackBar(context, S.of(context).documentUploadSuccessText);
Navigator.pop(context, true);
Navigator.pop(context, taskId);
} on PaperlessServerException catch (error, stackTrace) {
showErrorMessage(context, error, stackTrace);
} on PaperlessValidationErrors catch (errors) {

View File

@@ -52,7 +52,7 @@ class DocumentsCubit extends Cubit<DocumentsState> with HydratedMixin {
log("[DocumentsCubit] load");
emit(state.copyWith(isLoading: true));
try {
final result = await _api.find(state.filter);
final result = await _api.findAll(state.filter);
emit(state.copyWith(
isLoading: false,
hasLoaded: true,
@@ -67,11 +67,13 @@ class DocumentsCubit extends Cubit<DocumentsState> with HydratedMixin {
log("[DocumentsCubit] reload");
emit(state.copyWith(isLoading: true));
try {
final result = await _api.find(state.filter.copyWith(page: 1));
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));
@@ -81,7 +83,7 @@ class DocumentsCubit extends Cubit<DocumentsState> with HydratedMixin {
Future<void> _bulkReloadDocuments() async {
emit(state.copyWith(isLoading: true));
try {
final result = await _api.find(
final result = await _api.findAll(
state.filter.copyWith(
page: 1,
pageSize: state.documents.length,
@@ -106,7 +108,7 @@ class DocumentsCubit extends Cubit<DocumentsState> with HydratedMixin {
emit(state.copyWith(isLoading: true));
final newFilter = state.filter.copyWith(page: state.filter.page + 1);
try {
final result = await _api.find(newFilter);
final result = await _api.findAll(newFilter);
emit(
DocumentsState(
hasLoaded: true,
@@ -129,7 +131,7 @@ class DocumentsCubit extends Cubit<DocumentsState> with HydratedMixin {
log("[DocumentsCubit] updateFilter");
try {
emit(state.copyWith(isLoading: true));
final result = await _api.find(filter.copyWith(page: 1));
final result = await _api.findAll(filter.copyWith(page: 1));
emit(
DocumentsState(
@@ -163,7 +165,7 @@ class DocumentsCubit extends Cubit<DocumentsState> with HydratedMixin {
void toggleDocumentSelection(DocumentModel model) {
log("[DocumentsCubit] toggleSelection");
if (state.selection.contains(model)) {
if (state.selectedIds.contains(model.id)) {
emit(
state.copyWith(
selection: state.selection
@@ -183,6 +185,12 @@ 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());
@@ -196,7 +204,7 @@ class DocumentsCubit extends Cubit<DocumentsState> with HydratedMixin {
if (filter == null) {
return;
}
final results = await _api.find(filter.copyWith(page: 1));
final results = await _api.findAll(filter.copyWith(page: 1));
emit(
DocumentsState(
filter: filter,

View File

@@ -23,6 +23,8 @@ class DocumentsState extends Equatable {
this.selectedSavedViewId,
});
List<int> get selectedIds => selection.map((e) => e.id).toList();
int get currentPageNumber {
return filter.page;
}

View File

@@ -3,6 +3,7 @@ import 'dart:async';
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:flutter_form_builder/flutter_form_builder.dart';
import 'package:flutter_typeahead/flutter_typeahead.dart';
import 'package:form_builder_validators/form_builder_validators.dart';
import 'package:intl/intl.dart';
import 'package:paperless_api/paperless_api.dart';
@@ -15,14 +16,18 @@ 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';
import 'package:paperless_mobile/util.dart';
class DocumentEditPage extends StatefulWidget {
final FieldSuggestions suggestions;
const DocumentEditPage({
Key? key,
required this.suggestions,
}) : super(key: key);
@override
@@ -74,14 +79,17 @@ class _DocumentEditPageState extends State<DocumentEditPage> {
_buildTitleFormField(state.document.title).padded(),
_buildCreatedAtFormField(state.document.created).padded(),
_buildDocumentTypeFormField(
state.document.documentType, state.documentTypes)
.padded(),
state.document.documentType,
state.documentTypes,
).padded(),
_buildCorrespondentFormField(
state.document.correspondent, state.correspondents)
.padded(),
state.document.correspondent,
state.correspondents,
).padded(),
_buildStoragePathFormField(
state.document.storagePath, state.storagePaths)
.padded(),
state.document.storagePath,
state.storagePaths,
).padded(),
TagFormField(
initialValue:
IdsTagsQuery.included(state.document.tags.toList()),
@@ -99,58 +107,101 @@ class _DocumentEditPageState extends State<DocumentEditPage> {
}
Widget _buildStoragePathFormField(
int? initialId, Map<int, StoragePath> options) {
return LabelFormField<StoragePath>(
notAssignedSelectable: false,
formBuilderState: _formKey.currentState,
labelCreationWidgetBuilder: (initialValue) => RepositoryProvider(
create: (context) => context
.read<LabelRepository<StoragePath, StoragePathRepositoryState>>(),
child: AddStoragePathPage(initalValue: initialValue),
),
textFieldLabel: S.of(context).documentStoragePathPropertyLabel,
labelOptions: options,
initialValue: IdQueryParameter.fromId(initialId),
name: fkStoragePath,
prefixIcon: const Icon(Icons.folder_outlined),
int? initialId,
Map<int, StoragePath> options,
) {
return Column(
children: [
LabelFormField<StoragePath>(
notAssignedSelectable: false,
formBuilderState: _formKey.currentState,
labelCreationWidgetBuilder: (initialValue) => RepositoryProvider(
create: (context) => context.read<
LabelRepository<StoragePath, StoragePathRepositoryState>>(),
child: AddStoragePathPage(initalValue: initialValue),
),
textFieldLabel: S.of(context).documentStoragePathPropertyLabel,
labelOptions: options,
initialValue: IdQueryParameter.fromId(initialId),
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))),
),
),
],
);
}
Widget _buildCorrespondentFormField(
int? initialId, Map<int, Correspondent> options) {
return LabelFormField<Correspondent>(
notAssignedSelectable: false,
formBuilderState: _formKey.currentState,
labelCreationWidgetBuilder: (initialValue) => RepositoryProvider(
create: (context) => context.read<
LabelRepository<Correspondent, CorrespondentRepositoryState>>(),
child: AddCorrespondentPage(initialName: initialValue),
),
textFieldLabel: S.of(context).documentCorrespondentPropertyLabel,
labelOptions: options,
initialValue: IdQueryParameter.fromId(initialId),
name: fkCorrespondent,
prefixIcon: const Icon(Icons.person_outlined),
return Column(
children: [
LabelFormField<Correspondent>(
notAssignedSelectable: false,
formBuilderState: _formKey.currentState,
labelCreationWidgetBuilder: (initialValue) => RepositoryProvider(
create: (context) => context.read<
LabelRepository<Correspondent, CorrespondentRepositoryState>>(),
child: AddCorrespondentPage(initialName: initialValue),
),
textFieldLabel: S.of(context).documentCorrespondentPropertyLabel,
labelOptions: options,
initialValue: IdQueryParameter.fromId(initialId),
name: fkCorrespondent,
prefixIcon: const Icon(Icons.person_outlined),
),
if (widget.suggestions.hasSuggestedCorrespondents)
_buildSuggestionsSkeleton<int>(
suggestions: widget.suggestions.correspondents,
itemBuilder: (context, itemData) => ActionChip(
label: Text(options[itemData]!.name),
onPressed: () => _formKey.currentState?.fields[fkCorrespondent]
?.didChange((IdQueryParameter.fromId(itemData))),
),
),
],
);
}
Widget _buildDocumentTypeFormField(
int? initialId, Map<int, DocumentType> options) {
return LabelFormField<DocumentType>(
notAssignedSelectable: false,
formBuilderState: _formKey.currentState,
labelCreationWidgetBuilder: (currentInput) => RepositoryProvider(
create: (context) => context
.read<LabelRepository<DocumentType, DocumentTypeRepositoryState>>(),
child: AddDocumentTypePage(
initialName: currentInput,
int? initialId,
Map<int, DocumentType> options,
) {
return Column(
children: [
LabelFormField<DocumentType>(
notAssignedSelectable: false,
formBuilderState: _formKey.currentState,
labelCreationWidgetBuilder: (currentInput) => RepositoryProvider(
create: (context) => context.read<
LabelRepository<DocumentType, DocumentTypeRepositoryState>>(),
child: AddDocumentTypePage(
initialName: currentInput,
),
),
textFieldLabel: S.of(context).documentDocumentTypePropertyLabel,
initialValue: IdQueryParameter.fromId(initialId),
labelOptions: options,
name: fkDocumentType,
prefixIcon: const Icon(Icons.description_outlined),
),
),
textFieldLabel: S.of(context).documentDocumentTypePropertyLabel,
initialValue: IdQueryParameter.fromId(initialId),
labelOptions: options,
name: fkDocumentType,
prefixIcon: const Icon(Icons.description_outlined),
if (widget.suggestions.hasSuggestedDocumentTypes)
_buildSuggestionsSkeleton<int>(
suggestions: widget.suggestions.documentTypes,
itemBuilder: (context, itemData) => ActionChip(
label: Text(options[itemData]!.name),
onPressed: () => _formKey.currentState?.fields[fkDocumentType]
?.didChange(IdQueryParameter.fromId(itemData)),
),
),
],
);
}
@@ -198,16 +249,56 @@ class _DocumentEditPageState extends State<DocumentEditPage> {
}
Widget _buildCreatedAtFormField(DateTime? initialCreatedAtDate) {
return FormBuilderDateTimePicker(
inputType: InputType.date,
name: fkCreatedDate,
decoration: InputDecoration(
prefixIcon: const Icon(Icons.calendar_month_outlined),
label: Text(S.of(context).documentCreatedPropertyLabel),
),
initialValue: initialCreatedAtDate,
format: DateFormat("dd. MMMM yyyy"), //TODO: Localized date format
initialEntryMode: DatePickerEntryMode.calendar,
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
FormBuilderDateTimePicker(
inputType: InputType.date,
name: fkCreatedDate,
decoration: InputDecoration(
prefixIcon: const Icon(Icons.calendar_month_outlined),
label: Text(S.of(context).documentCreatedPropertyLabel),
),
initialValue: initialCreatedAtDate,
format: DateFormat("dd. MMMM yyyy"), //TODO: Localized date format
initialEntryMode: DatePickerEntryMode.calendar,
),
if (widget.suggestions.hasSuggestedDates)
_buildSuggestionsSkeleton<DateTime>(
suggestions: widget.suggestions.dates,
itemBuilder: (context, itemData) => ActionChip(
label: Text(DateFormat.yMd().format(itemData)),
onPressed: () => _formKey.currentState?.fields[fkCreatedDate]
?.didChange(itemData),
),
),
],
);
}
Widget _buildSuggestionsSkeleton<T>({
required Iterable<T> suggestions,
required ItemBuilder<T> itemBuilder,
}) {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
"Suggestions: ",
style: Theme.of(context).textTheme.bodySmall,
),
SizedBox(
height: 48,
child: ListView.separated(
scrollDirection: Axis.horizontal,
itemCount: suggestions.length,
itemBuilder: (context, index) =>
itemBuilder(context, suggestions.elementAt(index)),
separatorBuilder: (BuildContext context, int index) =>
const SizedBox(width: 4.0),
),
),
],
).padded();
}
}

View File

@@ -1,27 +1,30 @@
import 'package:badges/badges.dart' as b;
import 'package:collection/collection.dart';
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:infinite_scroll_pagination/infinite_scroll_pagination.dart';
import 'package:paperless_api/paperless_api.dart';
import 'package:paperless_mobile/core/bloc/connectivity_cubit.dart';
import 'package:paperless_mobile/core/repository/provider/label_repositories_provider.dart';
import 'package:paperless_mobile/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/bloc/documents_cubit.dart';
import 'package:paperless_mobile/features/documents/bloc/documents_state.dart';
import 'package:paperless_mobile/features/documents/view/widgets/documents_empty_state.dart';
import 'package:paperless_mobile/features/documents/view/widgets/grid/document_grid.dart';
import 'package:paperless_mobile/features/documents/view/widgets/list/document_list.dart';
import 'package:paperless_mobile/features/documents/view/widgets/list/adaptive_documents_view.dart';
import 'package:paperless_mobile/features/documents/view/widgets/new_items_loading_widget.dart';
import 'package:paperless_mobile/features/documents/view/widgets/search/document_filter_panel.dart';
import 'package:paperless_mobile/features/documents/view/widgets/selection/documents_page_app_bar.dart';
import 'package:paperless_mobile/features/documents/view/widgets/sort_documents_button.dart';
import 'package:paperless_mobile/features/home/view/widget/info_drawer.dart';
import 'package:paperless_mobile/features/labels/bloc/providers/labels_bloc_provider.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/view/saved_view_selection_widget.dart';
import 'package:paperless_mobile/features/settings/bloc/application_settings_cubit.dart';
import 'package:paperless_mobile/features/settings/model/application_settings_state.dart';
import 'package:paperless_mobile/features/settings/model/view_type.dart';
import 'package:paperless_mobile/features/tasks/cubit/task_status_cubit.dart';
import 'package:paperless_mobile/generated/l10n.dart';
import 'package:paperless_mobile/util.dart';
class DocumentFilterIntent {
@@ -42,9 +45,11 @@ class DocumentsPage extends StatefulWidget {
}
class _DocumentsPageState extends State<DocumentsPage> {
final _pagingController = PagingController<int, DocumentModel>(
firstPageKey: 1,
);
final ScrollController _scrollController = ScrollController();
double _offset = 0;
double _last = 0;
static const double _savedViewWidgetHeight = 78 + 16;
@override
void initState() {
@@ -55,12 +60,36 @@ class _DocumentsPageState extends State<DocumentsPage> {
} on PaperlessServerException catch (error, stackTrace) {
showErrorMessage(context, error, stackTrace);
}
_pagingController.addPageRequestListener(_loadNewPage);
_scrollController
..addListener(_listenForScrollChanges)
..addListener(_listenForLoadDataTrigger);
}
void _listenForLoadDataTrigger() {
final currState = context.read<DocumentsCubit>().state;
if (_scrollController.offset >=
_scrollController.position.maxScrollExtent &&
!currState.isLoading &&
!currState.isLastPageLoaded) {
_loadNewPage();
}
}
void _listenForScrollChanges() {
final current = _scrollController.offset;
_offset += _last - current;
if (_offset <= -_savedViewWidgetHeight) _offset = -_savedViewWidgetHeight;
if (_offset >= 0) _offset = 0;
_last = current;
if (_offset <= 0 && _offset >= -_savedViewWidgetHeight) {
setState(() {});
}
}
@override
void dispose() {
_pagingController.dispose();
_scrollController.dispose();
super.dispose();
}
@@ -78,6 +107,7 @@ class _DocumentsPageState extends State<DocumentsPage> {
}
},
builder: (context, connectivityState) {
const linearProgressIndicatorHeight = 4.0;
return Scaffold(
drawer: BlocProvider.value(
value: context.read<AuthenticationCubit>(),
@@ -85,16 +115,65 @@ class _DocumentsPageState extends State<DocumentsPage> {
afterInboxClosed: () => context.read<DocumentsCubit>().reload(),
),
),
appBar: PreferredSize(
preferredSize: const Size.fromHeight(
kToolbarHeight + linearProgressIndicatorHeight,
),
child: BlocBuilder<DocumentsCubit, DocumentsState>(
builder: (context, state) {
return AppBar(
title: Text(
"${S.of(context).documentsPageTitle} (${_formatDocumentCount(state.count)})",
),
actions: [
const SortDocumentsButton(),
BlocBuilder<ApplicationSettingsCubit,
ApplicationSettingsState>(
builder: (context, settingsState) => IconButton(
icon: Icon(
settingsState.preferredViewType == ViewType.grid
? Icons.list
: Icons.grid_view_rounded,
),
onPressed: () {
// Reset saved view widget position as scroll offset will be reset anyway.
setState(() {
_offset = 0;
_last = 0;
});
final cubit =
context.read<ApplicationSettingsCubit>();
cubit.setViewType(
cubit.state.preferredViewType.toggle());
},
),
),
],
bottom: PreferredSize(
preferredSize:
const Size.fromHeight(linearProgressIndicatorHeight),
child: state.isLoading
? const LinearProgressIndicator()
: const SizedBox(height: 4.0),
),
);
},
),
),
floatingActionButton: BlocBuilder<DocumentsCubit, DocumentsState>(
builder: (context, state) {
final appliedFiltersCount = state.filter.appliedFiltersCount;
return Badge.count(
//TODO: Wait for stable version of m3, then use AlignmentDirectional.topEnd
alignment: const AlignmentDirectional(44, -4),
isLabelVisible: appliedFiltersCount > 0,
count: state.filter.appliedFiltersCount,
backgroundColor: Colors.red,
textColor: Colors.white,
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: Theme.of(context).colorScheme.error,
child: FloatingActionButton(
child: const Icon(Icons.filter_alt_outlined),
onPressed: _openDocumentFilter,
@@ -103,12 +182,71 @@ class _DocumentsPageState extends State<DocumentsPage> {
},
),
resizeToAvoidBottomInset: true,
body: _buildBody(connectivityState),
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,
),
);
},
),
),
if (taskState.task != null &&
taskState.isSuccess &&
!taskState.task!.acknowledged)
_buildNewDocumentAvailableButton(context),
],
);
},
),
),
),
);
},
);
}
Align _buildNewDocumentAvailableButton(BuildContext context) {
return Align(
alignment: Alignment.bottomLeft,
child: FilledButton(
style: ButtonStyle(
backgroundColor:
MaterialStatePropertyAll(Theme.of(context).colorScheme.error),
),
child: Text("New document available!"),
onPressed: () {
context.read<TaskStatusCubit>().acknowledgeCurrentTask();
context.read<DocumentsCubit>().reload();
},
).paddedOnly(bottom: 24, left: 24),
);
}
void _openDocumentFilter() async {
final draggableSheetController = DraggableScrollableController();
final filterIntent = await showModalBottomSheet<DocumentFilterIntent>(
@@ -160,88 +298,45 @@ class _DocumentsPageState extends State<DocumentsPage> {
}
}
String _formatDocumentCount(int count) {
return count > 99 ? "99+" : count.toString();
}
Widget _buildBody(ConnectivityState connectivityState) {
final isConnected = connectivityState == ConnectivityState.connected;
return BlocBuilder<ApplicationSettingsCubit, ApplicationSettingsState>(
builder: (context, settings) {
return BlocBuilder<DocumentsCubit, DocumentsState>(
buildWhen: (previous, current) => !const ListEquality()
.equals(previous.documents, current.documents),
buildWhen: (previous, current) =>
!const ListEquality()
.equals(previous.documents, current.documents) ||
previous.selectedIds != current.selectedIds,
builder: (context, state) {
// Some ugly tricks to make it work with bloc, update pageController
_pagingController.value = PagingState(
itemList: state.documents,
nextPageKey: state.nextPageNumber,
);
late Widget child;
switch (settings.preferredViewType) {
case ViewType.list:
child = DocumentListView(
state: state,
onTap: _openDetails,
onSelected: _onSelected,
pagingController: _pagingController,
hasInternetConnection: isConnected,
onTagSelected: _addTagToFilter,
onCorrespondentSelected: _addCorrespondentToFilter,
onDocumentTypeSelected: _addDocumentTypeToFilter,
onStoragePathSelected: _addStoragePathToFilter,
);
break;
case ViewType.grid:
child = DocumentGridView(
state: state,
onTap: _openDetails,
onSelected: _onSelected,
pagingController: _pagingController,
hasInternetConnection: isConnected,
onTagSelected: _addTagToFilter,
onCorrespondentSelected: _addCorrespondentToFilter,
onDocumentTypeSelected: _addDocumentTypeToFilter,
onStoragePathSelected: _addStoragePathToFilter,
);
break;
}
if (state.hasLoaded && state.documents.isEmpty) {
child = SliverToBoxAdapter(
child: DocumentsEmptyState(
state: state,
onReset: () {
context.read<DocumentsCubit>().resetFilter();
context.read<DocumentsCubit>().unselectView();
},
),
return DocumentsEmptyState(
state: state,
onReset: () {
context.read<DocumentsCubit>().resetFilter();
context.read<DocumentsCubit>().unselectView();
},
);
}
return RefreshIndicator(
onRefresh: _onRefresh,
notificationPredicate: (_) => isConnected,
child: CustomScrollView(
slivers: [
DocumentsPageAppBar(
isOffline: connectivityState != ConnectivityState.connected,
actions: [
const SortDocumentsButton(),
IconButton(
icon: Icon(
settings.preferredViewType == ViewType.grid
? Icons.list
: Icons.grid_view,
),
onPressed: () => context
.read<ApplicationSettingsCubit>()
.setViewType(
settings.preferredViewType.toggle(),
),
),
],
),
child,
],
),
return AdaptiveDocumentsView(
viewType: settings.preferredViewType,
state: state,
scrollController: _scrollController,
onTap: _openDetails,
onSelected: _onSelected,
hasInternetConnection: isConnected,
onTagSelected: _addTagToFilter,
onCorrespondentSelected: _addCorrespondentToFilter,
onDocumentTypeSelected: _addDocumentTypeToFilter,
onStoragePathSelected: _addStoragePathToFilter,
pageLoadingWidget: const NewItemsLoadingWidget(),
beforeItems: const SizedBox(height: _savedViewWidgetHeight),
);
},
);
@@ -355,15 +450,9 @@ class _DocumentsPageState extends State<DocumentsPage> {
}
}
Future<void> _loadNewPage(int pageKey) async {
final documentsCubit = context.read<DocumentsCubit>();
final pageCount = documentsCubit.state
.inferPageCount(pageSize: documentsCubit.state.filter.pageSize);
if (pageCount <= pageKey + 1) {
_pagingController.nextPageKey = null;
}
Future<void> _loadNewPage() async {
try {
await documentsCubit.loadMore();
await context.read<DocumentsCubit>().loadMore();
} on PaperlessServerException catch (error, stackTrace) {
showErrorMessage(context, error, stackTrace);
}
@@ -376,8 +465,8 @@ class _DocumentsPageState extends State<DocumentsPage> {
Future<void> _onRefresh() async {
try {
// We do not await here on purpose so we can show a linear progress indicator below the app bar.
await context.read<DocumentsCubit>().reload();
await context.read<SavedViewCubit>().reload();
context.read<DocumentsCubit>().reload();
context.read<SavedViewCubit>().reload();
} on PaperlessServerException catch (error, stackTrace) {
showErrorMessage(context, error, stackTrace);
}

View File

@@ -1,65 +0,0 @@
import 'package:flutter/material.dart';
import 'package:paperless_api/paperless_api.dart';
import 'package:paperless_mobile/core/widgets/documents_list_loading_widget.dart';
import 'package:paperless_mobile/features/documents/bloc/documents_cubit.dart';
import 'package:paperless_mobile/features/documents/bloc/documents_state.dart';
import 'package:paperless_mobile/features/documents/view/widgets/grid/document_grid_item.dart';
import 'package:infinite_scroll_pagination/infinite_scroll_pagination.dart';
class DocumentGridView extends StatelessWidget {
final void Function(DocumentModel model) onTap;
final void Function(DocumentModel) onSelected;
final PagingController<int, DocumentModel> pagingController;
final DocumentsState state;
final bool hasInternetConnection;
final void Function(int tagId) onTagSelected;
final void Function(int correspondentId) onCorrespondentSelected;
final void Function(int correspondentId) onDocumentTypeSelected;
final void Function(int? id)? onStoragePathSelected;
const DocumentGridView({
super.key,
required this.onTap,
required this.pagingController,
required this.state,
required this.onSelected,
required this.hasInternetConnection,
required this.onTagSelected,
required this.onCorrespondentSelected,
required this.onDocumentTypeSelected,
this.onStoragePathSelected,
});
@override
Widget build(BuildContext context) {
return PagedSliverGrid<int, DocumentModel>(
gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount(
crossAxisCount: 2,
mainAxisSpacing: 4,
crossAxisSpacing: 4,
childAspectRatio: 1 / 2,
),
pagingController: pagingController,
builderDelegate: PagedChildBuilderDelegate(
itemBuilder: (context, item, index) {
return DocumentGridItem(
document: item,
onTap: onTap,
isSelected: state.selection.contains(item),
onSelected: onSelected,
isAtLeastOneSelected: state.selection.isNotEmpty,
isTagSelectedPredicate: (int tagId) {
return state.filter.tags is IdsTagsQuery
? (state.filter.tags as IdsTagsQuery)
.includedIds
.contains(tagId)
: false;
},
onTagSelected: onTagSelected,
);
},
noItemsFoundIndicatorBuilder: (context) =>
const DocumentsListLoadingWidget(), //TODO: Replace with grid loading widget
),
);
}
}

View File

@@ -13,7 +13,7 @@ class DocumentGridItem extends StatelessWidget {
final void Function(DocumentModel) onSelected;
final bool isAtLeastOneSelected;
final bool Function(int tagId) isTagSelectedPredicate;
final void Function(int tagId) onTagSelected;
final void Function(int tagId)? onTagSelected;
const DocumentGridItem({
Key? key,
@@ -57,9 +57,11 @@ class DocumentGridItem extends StatelessWidget {
crossAxisAlignment: CrossAxisAlignment.start,
children: [
CorrespondentWidget(
correspondentId: document.correspondent),
correspondentId: document.correspondent,
),
DocumentTypeWidget(
documentTypeId: document.documentType),
documentTypeId: document.documentType,
),
Text(
document.title,
maxLines: document.tags.isEmpty ? 3 : 2,

View File

@@ -0,0 +1,118 @@
import 'package:flutter/material.dart';
import 'package:paperless_api/paperless_api.dart';
import 'package:paperless_mobile/core/repository/provider/label_repositories_provider.dart';
import 'package:paperless_mobile/features/documents/bloc/documents_state.dart';
import 'package:paperless_mobile/features/documents/view/widgets/grid/document_grid_item.dart';
import 'package:paperless_mobile/features/documents/view/widgets/list/document_list_item.dart';
import 'package:paperless_mobile/features/settings/model/view_type.dart';
class AdaptiveDocumentsView extends StatelessWidget {
final ViewType viewType;
final Widget beforeItems;
final void Function(DocumentModel) onTap;
final void Function(DocumentModel) onSelected;
final ScrollController scrollController;
final DocumentsState state;
final bool hasInternetConnection;
final bool isLabelClickable;
final void Function(int id)? onTagSelected;
final void Function(int? id)? onCorrespondentSelected;
final void Function(int? id)? onDocumentTypeSelected;
final void Function(int? id)? onStoragePathSelected;
final Widget pageLoadingWidget;
const AdaptiveDocumentsView({
super.key,
required this.onTap,
required this.scrollController,
required this.state,
required this.onSelected,
required this.hasInternetConnection,
this.isLabelClickable = true,
this.onTagSelected,
this.onCorrespondentSelected,
this.onDocumentTypeSelected,
this.onStoragePathSelected,
required this.pageLoadingWidget,
required this.beforeItems,
required this.viewType,
});
@override
Widget build(BuildContext context) {
return CustomScrollView(
controller: scrollController,
slivers: [
SliverToBoxAdapter(child: beforeItems),
if (viewType == ViewType.list) _buildListView() else _buildGridView(),
],
);
}
SliverList _buildListView() {
return SliverList(
delegate: SliverChildBuilderDelegate(
childCount: state.documents.length,
(context, index) {
final document = state.documents.elementAt(index);
return LabelRepositoriesProvider(
child: DocumentListItem(
isLabelClickable: isLabelClickable,
document: document,
onTap: onTap,
isSelected: state.selectedIds.contains(document.id),
onSelected: onSelected,
isAtLeastOneSelected: state.selection.isNotEmpty,
isTagSelectedPredicate: (int tagId) {
return state.filter.tags is IdsTagsQuery
? (state.filter.tags as IdsTagsQuery)
.includedIds
.contains(tagId)
: false;
},
onTagSelected: onTagSelected,
onCorrespondentSelected: onCorrespondentSelected,
onDocumentTypeSelected: onDocumentTypeSelected,
onStoragePathSelected: onStoragePathSelected,
),
);
},
),
);
}
Widget _buildGridView() {
return SliverGrid.builder(
gridDelegate: const SliverGridDelegateWithMaxCrossAxisExtent(
maxCrossAxisExtent: 178,
mainAxisSpacing: 4,
crossAxisSpacing: 4,
childAspectRatio: 1 / 2,
),
itemCount: state.documents.length,
itemBuilder: (context, index) {
if (state.hasLoaded &&
state.isLoading &&
index == state.documents.length) {
return Center(child: pageLoadingWidget);
}
final document = state.documents.elementAt(index);
return DocumentGridItem(
document: document,
onTap: onTap,
isSelected: state.selectedIds.contains(document.id),
onSelected: onSelected,
isAtLeastOneSelected: state.selection.isNotEmpty,
isTagSelectedPredicate: (int tagId) {
return state.filter.tags is IdsTagsQuery
? (state.filter.tags as IdsTagsQuery)
.includedIds
.contains(tagId)
: false;
},
onTagSelected: onTagSelected,
);
},
);
}
}

View File

@@ -1,73 +0,0 @@
import 'package:flutter/material.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/core/widgets/offline_widget.dart';
import 'package:paperless_mobile/features/documents/bloc/documents_cubit.dart';
import 'package:paperless_mobile/features/documents/bloc/documents_state.dart';
import 'package:paperless_mobile/features/documents/view/widgets/list/document_list_item.dart';
import 'package:infinite_scroll_pagination/infinite_scroll_pagination.dart';
class DocumentListView extends StatelessWidget {
final void Function(DocumentModel) onTap;
final void Function(DocumentModel) onSelected;
final PagingController<int, DocumentModel> pagingController;
final DocumentsState state;
final bool hasInternetConnection;
final bool isLabelClickable;
final void Function(int id)? onTagSelected;
final void Function(int? id)? onCorrespondentSelected;
final void Function(int? id)? onDocumentTypeSelected;
final void Function(int? id)? onStoragePathSelected;
const DocumentListView({
super.key,
required this.onTap,
required this.pagingController,
required this.state,
required this.onSelected,
required this.hasInternetConnection,
this.isLabelClickable = true,
this.onTagSelected,
this.onCorrespondentSelected,
this.onDocumentTypeSelected,
this.onStoragePathSelected,
});
@override
Widget build(BuildContext context) {
return PagedSliverList<int, DocumentModel>(
pagingController: pagingController,
builderDelegate: PagedChildBuilderDelegate(
animateTransitions: true,
itemBuilder: (context, document, index) {
return LabelRepositoriesProvider(
child: DocumentListItem(
isLabelClickable: isLabelClickable,
document: document,
onTap: onTap,
isSelected: state.selection.contains(document),
onSelected: onSelected,
isAtLeastOneSelected: state.selection.isNotEmpty,
isTagSelectedPredicate: (int tagId) {
return state.filter.tags is IdsTagsQuery
? (state.filter.tags as IdsTagsQuery)
.includedIds
.contains(tagId)
: false;
},
onTagSelected: onTagSelected,
onCorrespondentSelected: onCorrespondentSelected,
onDocumentTypeSelected: onDocumentTypeSelected,
onStoragePathSelected: onStoragePathSelected,
),
);
},
noItemsFoundIndicatorBuilder: (context) => hasInternetConnection
? const DocumentsListLoadingWidget()
: const OfflineWidget(),
),
);
}
}

View File

@@ -38,6 +38,7 @@ class DocumentListItem extends StatelessWidget {
Widget build(BuildContext context) {
return SizedBox(
child: ListTile(
trailing: Text("${document.id}"),
dense: true,
selected: isSelected,
onTap: () => _onTap(),

View File

@@ -0,0 +1,10 @@
import 'package:flutter/material.dart';
class NewItemsLoadingWidget extends StatelessWidget {
const NewItemsLoadingWidget({super.key});
@override
Widget build(BuildContext context) {
return const CircularProgressIndicator();
}
}

View File

@@ -138,3 +138,19 @@ class _DocumentsPageAppBarState extends State<DocumentsPageAppBar> {
return count > 99 ? "99+" : count.toString();
}
}
class ScrollListener extends ChangeNotifier {
double top = 0;
double _last = 0;
ScrollListener.initialise(ScrollController controller, [double height = 56]) {
controller.addListener(() {
final current = controller.offset;
top += _last - current;
if (top <= -height) top = -height;
if (top >= 0) top = 0;
_last = current;
if (top <= 0 && top >= -height) notifyListeners();
});
}
}

View File

@@ -47,46 +47,6 @@ class _HomePageState extends State<HomePage> {
@override
void initState() {
super.initState();
LocalNotificationService.instance.notifyTaskChanged(
Task(
id: 100,
dateCreated: DateTime.now(),
dateDone: DateTime.now(),
taskFileName: "test_file.pdf",
status: TaskStatus.started,
taskId: "abc-def-123-456",
type: "file",
),
);
Future.delayed(const Duration(seconds: 5), () {
LocalNotificationService.instance.notifyTaskChanged(
Task(
id: 100,
dateCreated: DateTime.now(),
dateDone: DateTime.now(),
taskFileName: "test_file.pdf",
status: TaskStatus.pending,
taskId: "abc-def-123-456",
type: "file",
),
);
});
Future.delayed(const Duration(seconds: 10), () {
LocalNotificationService.instance.notifyTaskChanged(
Task(
id: 100,
acknowledged: false,
dateCreated: DateTime.now(),
dateDone: DateTime.now(),
relatedDocumentId: 180,
result: "New document successfully created.",
status: TaskStatus.success,
taskFileName: "test_file.pdf",
taskId: "abc-def-123-456",
type: "file",
),
);
});
_initializeData(context);
WidgetsBinding.instance.addPostFrameCallback((timeStamp) {
_listenForReceivedFiles();
@@ -195,7 +155,14 @@ class _HomePageState extends State<HomePage> {
},
),
BlocListener<TaskStatusCubit, TaskStatusState>(
listener: (context, state) {},
listener: (context, state) {
if (state.task != null) {
// Handle local notifications on task change (only when app is running for now).
context
.read<LocalNotificationService>()
.notifyTaskChanged(state.task!);
}
},
),
],
child: Scaffold(

View File

@@ -1,5 +1,4 @@
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:injectable/injectable.dart';
import 'package:paperless_api/paperless_api.dart';
import 'package:paperless_mobile/core/repository/label_repository.dart';
import 'package:paperless_mobile/core/repository/state/impl/tag_repository_state.dart';
@@ -28,7 +27,7 @@ class InboxCubit extends Cubit<InboxState> {
));
}
final inboxDocuments = await _documentsApi
.find(DocumentFilter(
.findAll(DocumentFilter(
tags: AnyAssignedTagsQuery(tagIds: inboxTags),
sortField: SortField.added,
))

View File

@@ -10,7 +10,7 @@ class TagsWidget extends StatefulWidget {
final Iterable<int> tagIds;
final bool isMultiLine;
final VoidCallback? afterTagTapped;
final void Function(int tagId) onTagSelected;
final void Function(int tagId)? onTagSelected;
final bool isClickable;
final bool Function(int id) isSelectedPredicate;
@@ -42,7 +42,7 @@ class _TagsWidgetState extends State<TagsWidget> {
afterTagTapped: widget.afterTagTapped,
isClickable: widget.isClickable,
isSelected: widget.isSelectedPredicate(id),
onSelected: () => widget.onTagSelected(id),
onSelected: () => widget.onTagSelected?.call(id),
),
)
.toList();

View File

@@ -11,7 +11,7 @@ class LinkedDocumentsCubit extends Cubit<LinkedDocumentsState> {
}
Future<void> _initialize() async {
final documents = await _api.find(
final documents = await _api.findAll(
state.filter.copyWith(
pageSize: 100,
),

View File

@@ -1,6 +1,5 @@
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:infinite_scroll_pagination/infinite_scroll_pagination.dart';
import 'package:paperless_api/paperless_api.dart';
import 'package:paperless_mobile/core/widgets/documents_list_loading_widget.dart';
import 'package:paperless_mobile/features/document_details/bloc/document_details_cubit.dart';
@@ -18,15 +17,6 @@ class LinkedDocumentsPage extends StatefulWidget {
}
class _LinkedDocumentsPageState extends State<LinkedDocumentsPage> {
final _pagingController =
PagingController<int, DocumentModel>(firstPageKey: 1);
@override
void initState() {
super.initState();
_pagingController.nextPageKey = null;
}
@override
Widget build(BuildContext context) {
return Scaffold(
@@ -38,8 +28,6 @@ class _LinkedDocumentsPageState extends State<LinkedDocumentsPage> {
if (!state.isLoaded) {
return const DocumentsListLoadingWidget();
}
_pagingController.itemList = state.documents!.results;
return Column(
children: [
Text(
@@ -48,42 +36,34 @@ class _LinkedDocumentsPageState extends State<LinkedDocumentsPage> {
style: Theme.of(context).textTheme.bodySmall,
),
Expanded(
child: CustomScrollView(
slivers: [
PagedSliverList<int, DocumentModel>(
pagingController: _pagingController,
builderDelegate: PagedChildBuilderDelegate(
animateTransitions: true,
itemBuilder: (context, document, index) {
return DocumentListItem(
isLabelClickable: false,
document: document,
onTap: (doc) {
Navigator.push(
context,
MaterialPageRoute(
builder: (context) => BlocProvider(
create: (context) => DocumentDetailsCubit(
context.read<PaperlessDocumentsApi>(),
document,
),
child: const DocumentDetailsPage(
isLabelClickable: false,
allowEdit: false,
),
),
),
);
},
isSelected: false,
isAtLeastOneSelected: false,
isTagSelectedPredicate: (_) => false,
onTagSelected: (int tag) {},
);
},
),
),
],
child: ListView.builder(
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,
),
),
),
);
},
isSelected: false,
isAtLeastOneSelected: false,
isTagSelectedPredicate: (_) => false,
onTagSelected: (int tag) {},
);
},
),
),
],

View File

@@ -1,7 +1,7 @@
import 'package:dio/dio.dart';
import 'package:hydrated_bloc/hydrated_bloc.dart';
import 'package:paperless_api/paperless_api.dart';
import 'package:paperless_mobile/core/security/authentication_aware_dio_manager.dart';
import 'package:paperless_mobile/core/security/session_manager.dart';
import 'package:paperless_mobile/features/login/bloc/authentication_state.dart';
import 'package:paperless_mobile/features/login/model/authentication_information.dart';
import 'package:paperless_mobile/features/login/model/client_certificate.dart';
@@ -12,7 +12,7 @@ class AuthenticationCubit extends Cubit<AuthenticationState>
with HydratedMixin<AuthenticationState> {
final LocalAuthenticationService _localAuthService;
final PaperlessAuthenticationApi _authApi;
final AuthenticationAwareDioManager _dioWrapper;
final SessionManager _dioWrapper;
AuthenticationCubit(
this._localAuthService,

View File

@@ -1,8 +1,6 @@
import 'package:injectable/injectable.dart';
import 'package:local_auth/local_auth.dart';
import 'package:paperless_mobile/core/store/local_vault.dart';
@lazySingleton
class LocalAuthenticationService {
final LocalVault localStore;
final LocalAuthentication localAuthentication;

View File

@@ -1,11 +0,0 @@
import 'package:bloc/bloc.dart';
import 'package:equatable/equatable.dart';
import 'package:flutter/material.dart';
part 'notification_state.dart';
class NotificationCubit extends Cubit<NotificationState> {
NotificationCubit() : super(NotificationInitialState());
void navigateTo(String route, dynamic args) {}
}

View File

@@ -1,16 +0,0 @@
part of 'notification_cubit.dart';
abstract class NotificationState extends Equatable {
const NotificationState();
@override
List<Object> get props => [];
}
class NotificationInitialState extends NotificationState {}
class NotificationOpenDocumentDetailsPageState extends NotificationState {
final int documentId;
const NotificationOpenDocumentDetailsPageState(this.documentId);
}

View File

@@ -11,9 +11,7 @@ class LocalNotificationService {
final FlutterLocalNotificationsPlugin _plugin =
FlutterLocalNotificationsPlugin();
LocalNotificationService._();
static final LocalNotificationService instance = LocalNotificationService._();
LocalNotificationService();
Future<void> initialize() async {
const AndroidInitializationSettings initializationSettingsAndroid =
@@ -71,7 +69,7 @@ class LocalNotificationService {
body = task.taskFileName;
timestampMillis = task.dateDone!.millisecondsSinceEpoch;
payload = CreateDocumentSuccessNotificationResponsePayload(
task.relatedDocumentId!,
task.relatedDocument!,
);
break;
default:

View File

@@ -4,6 +4,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/extensions/flutter_extensions.dart';
import 'package:paperless_mobile/features/documents/bloc/documents_cubit.dart';
import 'package:paperless_mobile/features/documents/bloc/documents_state.dart';
import 'package:paperless_mobile/features/documents/view/widgets/selection/confirm_delete_saved_view_dialog.dart';
@@ -31,91 +32,97 @@ class SavedViewSelectionWidget extends StatelessWidget {
return BlocBuilder<ConnectivityCubit, ConnectivityState>(
builder: (context, connectivityState) {
final hasInternetConnection = connectivityState.isConnected;
return Column(
mainAxisAlignment: MainAxisAlignment.end,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
BlocBuilder<SavedViewCubit, SavedViewState>(
builder: (context, state) {
if (!state.hasLoaded) {
return _buildLoadingWidget(context);
}
if (state.value.isEmpty) {
return Text(S.of(context).savedViewsEmptyStateText);
}
return SizedBox(
height: height,
child: ListView.separated(
itemCount: state.value.length,
scrollDirection: Axis.horizontal,
itemBuilder: (context, index) {
final view = state.value.values.elementAt(index);
return GestureDetector(
onLongPress: hasInternetConnection
? () => _onDelete(context, view)
: null,
child: BlocBuilder<DocumentsCubit, DocumentsState>(
builder: (context, docState) {
return FilterChip(
label: Text(
state.value.values.toList()[index].name,
),
selected: view.id == docState.selectedSavedViewId,
onSelected: enabled && hasInternetConnection
? (isSelected) =>
_onSelected(isSelected, context, view)
: null,
);
},
),
);
},
separatorBuilder: (context, index) => const SizedBox(
width: 4.0,
),
),
);
},
),
BlocBuilder<SavedViewCubit, SavedViewState>(
builder: (context, state) {
return Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text(
S.of(context).savedViewsLabel,
style: Theme.of(context).textTheme.titleSmall,
),
BlocBuilder<DocumentsCubit, DocumentsState>(
buildWhen: (previous, current) =>
previous.filter != current.filter,
builder: (context, docState) {
return TextButton.icon(
icon: const Icon(Icons.add),
onPressed: (enabled &&
state.hasLoaded &&
hasInternetConnection)
? () => _onCreatePressed(context, docState.filter)
return SizedBox(
height: height,
child: Column(
mainAxisAlignment: MainAxisAlignment.start,
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
BlocBuilder<SavedViewCubit, SavedViewState>(
builder: (context, state) {
if (!state.hasLoaded) {
return _buildLoadingWidget(context);
}
if (state.value.isEmpty) {
return Text(S.of(context).savedViewsEmptyStateText);
}
return SizedBox(
height: 38,
child: ListView.separated(
itemCount: state.value.length,
scrollDirection: Axis.horizontal,
itemBuilder: (context, index) {
final view = state.value.values.elementAt(index);
return GestureDetector(
onLongPress: hasInternetConnection
? () => _onDelete(context, view)
: null,
label: Text(S.of(context).savedViewCreateNewLabel),
child: BlocBuilder<DocumentsCubit, DocumentsState>(
builder: (context, docState) {
final view = state.value.values.toList()[index];
return FilterChip(
label: Text(
view.name,
),
selected:
view.id == docState.selectedSavedViewId,
onSelected: enabled && hasInternetConnection
? (isSelected) =>
_onSelected(isSelected, context, view)
: null,
);
},
),
);
},
separatorBuilder: (context, index) => const SizedBox(
width: 4.0,
),
),
],
);
},
),
],
);
},
),
BlocBuilder<SavedViewCubit, SavedViewState>(
builder: (context, state) {
return Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text(
S.of(context).savedViewsLabel,
style: Theme.of(context).textTheme.titleSmall,
),
BlocBuilder<DocumentsCubit, DocumentsState>(
buildWhen: (previous, current) =>
previous.filter != current.filter,
builder: (context, docState) {
return TextButton.icon(
icon: const Icon(Icons.add),
onPressed: (enabled &&
state.hasLoaded &&
hasInternetConnection)
? () =>
_onCreatePressed(context, docState.filter)
: null,
label: Text(S.of(context).savedViewCreateNewLabel),
);
},
),
],
);
},
),
],
).padded(),
);
},
);
}
Widget _buildLoadingWidget(BuildContext context) {
final r = Random(123456789);
return SizedBox(
height: height,
width: double.infinity,
height: 38,
width: MediaQuery.of(context).size.width,
child: Shimmer.fromColors(
baseColor: Theme.of(context).brightness == Brightness.light
? Colors.grey[300]!
@@ -123,14 +130,35 @@ class SavedViewSelectionWidget extends StatelessWidget {
highlightColor: Theme.of(context).brightness == Brightness.light
? Colors.grey[100]!
: Colors.grey[600]!,
child: ListView.separated(
child: ListView(
scrollDirection: Axis.horizontal,
physics: const NeverScrollableScrollPhysics(),
itemCount: 10,
itemBuilder: (context, index) => FilterChip(
label: SizedBox(width: r.nextInt((index * 20) + 50).toDouble()),
onSelected: null),
separatorBuilder: (context, index) => const SizedBox(width: 4.0),
children: [
FilterChip(
label: const SizedBox(width: 32),
onSelected: (_) {},
),
const SizedBox(width: 4.0),
FilterChip(
label: const SizedBox(width: 64),
onSelected: (_) {},
),
const SizedBox(width: 4.0),
FilterChip(
label: const SizedBox(width: 100),
onSelected: (_) {},
),
const SizedBox(width: 4.0),
FilterChip(
label: const SizedBox(width: 32),
onSelected: (_) {},
),
const SizedBox(width: 4.0),
FilterChip(
label: const SizedBox(width: 48),
onSelected: (_) {},
),
],
),
),
);

View File

@@ -24,6 +24,7 @@ import 'package:paperless_mobile/features/documents/view/pages/document_view.dar
import 'package:paperless_mobile/features/home/view/widget/info_drawer.dart';
import 'package:paperless_mobile/features/scan/bloc/document_scanner_cubit.dart';
import 'package:paperless_mobile/features/scan/view/widgets/grid_image_item_widget.dart';
import 'package:paperless_mobile/features/tasks/cubit/task_status_cubit.dart';
import 'package:paperless_mobile/generated/l10n.dart';
import 'package:paperless_mobile/util.dart';
import 'package:path/path.dart' as p;
@@ -140,7 +141,7 @@ class _ScannerPageState extends State<ScannerPage>
final file = await _assembleFileBytes(
context.read<DocumentScannerCubit>().state,
);
final uploaded = await Navigator.of(context).push(
final taskId = await Navigator.of(context).push(
MaterialPageRoute(
builder: (_) => LabelRepositoriesProvider(
child: BlocProvider(
@@ -165,8 +166,10 @@ class _ScannerPageState extends State<ScannerPage>
),
) ??
false;
if (uploaded) {
if (taskId != null) {
// For paperless version older than 1.11.3, task id will always be null!
context.read<DocumentScannerCubit>().reset();
context.read<TaskStatusCubit>().listenToTaskChanges(taskId);
}
}

View File

@@ -7,20 +7,30 @@ class TaskStatusCubit extends Cubit<TaskStatusState> {
final PaperlessTasksApi _api;
TaskStatusCubit(this._api) : super(const TaskStatusState());
void startListeningToTask(String taskId) {
void listenToTaskChanges(String taskId) {
_api
.listenForTaskChanges(taskId)
.forEach(
(element) => TaskStatusState(
isListening: true,
isAcknowledged: false,
task: element,
(element) => emit(
TaskStatusState(
isListening: true,
task: element,
),
),
)
.whenComplete(() => emit(state.copyWith(isListening: false)));
}
void acknowledgeCurrentTask() {
emit(state.copyWith(isListening: false, isAcknowledged: true));
Future<void> acknowledgeCurrentTask() async {
if (state.task == null) {
return;
}
final task = await _api.acknowledgeTask(state.task!);
emit(
state.copyWith(
task: task,
isListening: false,
),
);
}
}

View File

@@ -3,22 +3,20 @@ part of 'task_status_cubit.dart';
class TaskStatusState extends Equatable {
final Task? task;
final bool isListening;
final bool isAcknowledged;
const TaskStatusState({
this.task,
this.isListening = false,
this.isAcknowledged = false,
});
bool get isActive => isListening && !isAcknowledged;
bool get isSuccess => task?.status == TaskStatus.success;
bool get isAcknowledged => task?.acknowledged ?? false;
String? get taskId => task?.taskId;
@override
List<Object> get props => [];
List<Object?> get props => [task, isListening];
TaskStatusState copyWith({
Task? task,
@@ -28,7 +26,6 @@ class TaskStatusState extends Equatable {
return TaskStatusState(
task: task ?? this.task,
isListening: isListening ?? this.isListening,
isAcknowledged: isAcknowledged ?? this.isAcknowledged,
);
}
}

View File

@@ -194,6 +194,8 @@
"@editLabelPageConfirmDeletionDialogTitle": {},
"editLabelPageDeletionDialogText": "Dokumenty mají přiřazen tento štítek. Odstraněním štítku bude označení odstraněno. Pokračovat?",
"@editLabelPageDeletionDialogText": {},
"errorMessageAcknowledgeTasksError": "",
"@errorMessageAcknowledgeTasksError": {},
"errorMessageAuthenticationFailed": "Přihlášení selhalo, zkuste to znovu.",
"@errorMessageAuthenticationFailed": {},
"errorMessageAutocompleteQueryError": "Při automatickém doplnění požadavku došlo k chybě.",
@@ -250,6 +252,8 @@
"@errorMessageStoragePathCreateFailed": {},
"errorMessageStoragePathLoadFailed": "Nelze načíst cestu k úložišti.",
"@errorMessageStoragePathLoadFailed": {},
"errorMessageSuggestionsQueryError": "",
"@errorMessageSuggestionsQueryError": {},
"errorMessageTagCreateFailed": "Nelze vytvořit tag, zkuste to znovu.",
"@errorMessageTagCreateFailed": {},
"errorMessageTagLoadFailed": "Nelze načíst tagy.",
@@ -260,25 +264,25 @@
"@errorMessageUnsupportedFileFormat": {},
"errorReportLabel": "NAHLÁSIT",
"@errorReportLabel": {},
"extendedDateRangeDialogAbsoluteLabel": "Absolute",
"extendedDateRangeDialogAbsoluteLabel": "",
"@extendedDateRangeDialogAbsoluteLabel": {},
"extendedDateRangeDialogHintText": "Hint: Apart from concrete dates, you can also specify a time range relative to the current date.",
"extendedDateRangeDialogHintText": "",
"@extendedDateRangeDialogHintText": {},
"extendedDateRangeDialogRelativeAmountLabel": "Amount",
"extendedDateRangeDialogRelativeAmountLabel": "",
"@extendedDateRangeDialogRelativeAmountLabel": {},
"extendedDateRangeDialogRelativeLabel": "Relative",
"extendedDateRangeDialogRelativeLabel": "",
"@extendedDateRangeDialogRelativeLabel": {},
"extendedDateRangeDialogRelativeLastLabel": "Last",
"extendedDateRangeDialogRelativeLastLabel": "",
"@extendedDateRangeDialogRelativeLastLabel": {},
"extendedDateRangeDialogRelativeTimeUnitLabel": "Time unit",
"extendedDateRangeDialogRelativeTimeUnitLabel": "",
"@extendedDateRangeDialogRelativeTimeUnitLabel": {},
"extendedDateRangeDialogTitle": "Select date range",
"extendedDateRangeDialogTitle": "",
"@extendedDateRangeDialogTitle": {},
"extendedDateRangePickerAfterLabel": "After",
"extendedDateRangePickerAfterLabel": "",
"@extendedDateRangePickerAfterLabel": {},
"extendedDateRangePickerBeforeLabel": "Before",
"extendedDateRangePickerBeforeLabel": "",
"@extendedDateRangePickerBeforeLabel": {},
"extendedDateRangePickerDayText": "{count, plural, zero{} one{day} other{days}}",
"extendedDateRangePickerDayText": "{count, plural, other{}}",
"@extendedDateRangePickerDayText": {
"placeholders": {
"count": {}
@@ -298,9 +302,9 @@
"count": {}
}
},
"extendedDateRangePickerLastText": "Last",
"extendedDateRangePickerLastText": "",
"@extendedDateRangePickerLastText": {},
"extendedDateRangePickerLastWeeksLabel": "{count, plural, zero{} one{Last week} other{Last {count} weeks}}",
"extendedDateRangePickerLastWeeksLabel": "{count, plural, other{}}",
"@extendedDateRangePickerLastWeeksLabel": {
"placeholders": {
"count": {}
@@ -312,7 +316,7 @@
"count": {}
}
},
"extendedDateRangePickerMonthText": "{count, plural, zero{} one{month} other{months}}",
"extendedDateRangePickerMonthText": "{count, plural, other{}}",
"@extendedDateRangePickerMonthText": {
"placeholders": {
"count": {}
@@ -320,13 +324,13 @@
},
"extendedDateRangePickerToLabel": "Do",
"@extendedDateRangePickerToLabel": {},
"extendedDateRangePickerWeekText": "{count, plural, zero{} one{week} other{weeks}}",
"extendedDateRangePickerWeekText": "{count, plural, other{}}",
"@extendedDateRangePickerWeekText": {
"placeholders": {
"count": {}
}
},
"extendedDateRangePickerYearText": "{count, plural, zero{} one{year} other{years}}",
"extendedDateRangePickerYearText": "{count, plural, other{}}",
"@extendedDateRangePickerYearText": {
"placeholders": {
"count": {}
@@ -428,7 +432,7 @@
"@loginPageClientCertificateSettingLabel": {},
"loginPageClientCertificateSettingSelectFileText": "Vybrat soubor...",
"@loginPageClientCertificateSettingSelectFileText": {},
"loginPageContinueLabel": "Continue",
"loginPageContinueLabel": "",
"@loginPageContinueLabel": {},
"loginPageIncorrectOrMissingCertificatePassphraseErrorMessageText": "Chybná nebo chybějící heslová fráze certifikátu.",
"@loginPageIncorrectOrMissingCertificatePassphraseErrorMessageText": {},
@@ -438,27 +442,27 @@
"@loginPagePasswordFieldLabel": {},
"loginPagePasswordValidatorMessageText": "Heslo nesmí být prázdné.",
"@loginPagePasswordValidatorMessageText": {},
"loginPageReachabilityInvalidClientCertificateConfigurationText": "Incorrect or missing client certificate passphrase.",
"loginPageReachabilityInvalidClientCertificateConfigurationText": "",
"@loginPageReachabilityInvalidClientCertificateConfigurationText": {},
"loginPageReachabilityMissingClientCertificateText": "A client certificate was expected but not sent. Please provide a certificate.",
"loginPageReachabilityMissingClientCertificateText": "",
"@loginPageReachabilityMissingClientCertificateText": {},
"loginPageReachabilityNotReachableText": "Could not establish a connection to the server.",
"loginPageReachabilityNotReachableText": "",
"@loginPageReachabilityNotReachableText": {},
"loginPageReachabilitySuccessText": "Connection successfully established.",
"loginPageReachabilitySuccessText": "",
"@loginPageReachabilitySuccessText": {},
"loginPageReachabilityUnresolvedHostText": "Host could not be resolved. Please check the server address.",
"loginPageReachabilityUnresolvedHostText": "",
"@loginPageReachabilityUnresolvedHostText": {},
"loginPageServerUrlFieldLabel": "'Adresa serveru",
"@loginPageServerUrlFieldLabel": {},
"loginPageServerUrlValidatorMessageInvalidAddressText": "Invalid address.",
"loginPageServerUrlValidatorMessageInvalidAddressText": "",
"@loginPageServerUrlValidatorMessageInvalidAddressText": {},
"loginPageServerUrlValidatorMessageRequiredText": "Adresa serveru nesmí být prázdná.",
"@loginPageServerUrlValidatorMessageRequiredText": {},
"loginPageSignInButtonLabel": "Sign In",
"loginPageSignInButtonLabel": "",
"@loginPageSignInButtonLabel": {},
"loginPageSignInTitle": "Sign In",
"loginPageSignInTitle": "",
"@loginPageSignInTitle": {},
"loginPageSignInToPrefixText": "Sign in to {serverAddress}",
"loginPageSignInToPrefixText": "",
"@loginPageSignInToPrefixText": {
"placeholders": {
"serverAddress": {}
@@ -476,7 +480,7 @@
"@onboardingDoneButtonLabel": {},
"onboardingNextButtonLabel": "Další",
"@onboardingNextButtonLabel": {},
"receiveSharedFilePermissionDeniedMessage": "Could not access the received file. Please try to open the app before sharing.",
"receiveSharedFilePermissionDeniedMessage": "",
"@receiveSharedFilePermissionDeniedMessage": {},
"referencedDocumentsReadOnlyHintText": "Tento náhled nelze upravovat! Nelze upravovat nebo odstraňovat dokumenty. Bude načteno maximálně 100 odkazovaných dokumentů.",
"@referencedDocumentsReadOnlyHintText": {},
@@ -540,12 +544,12 @@
"@tagInboxTagPropertyLabel": {},
"uploadPageAutomaticallInferredFieldsHintText": "Pokud specifikuješ hodnoty pro tato pole, paperless instance nebude automaticky přiřazovat naučené hodnoty. Pokud mají být tato pole automaticky vyplňována, nevyplňujte zde nic.",
"@uploadPageAutomaticallInferredFieldsHintText": {},
"verifyIdentityPageDescriptionText": "Use the configured biometric factor to authenticate and unlock your documents.",
"verifyIdentityPageDescriptionText": "",
"@verifyIdentityPageDescriptionText": {},
"verifyIdentityPageLogoutButtonLabel": "Disconnect",
"verifyIdentityPageLogoutButtonLabel": "",
"@verifyIdentityPageLogoutButtonLabel": {},
"verifyIdentityPageTitle": "Verify your identity",
"verifyIdentityPageTitle": "",
"@verifyIdentityPageTitle": {},
"verifyIdentityPageVerifyIdentityButtonLabel": "Verify Identity",
"verifyIdentityPageVerifyIdentityButtonLabel": "",
"@verifyIdentityPageVerifyIdentityButtonLabel": {}
}

View File

@@ -194,6 +194,8 @@
"@editLabelPageConfirmDeletionDialogTitle": {},
"editLabelPageDeletionDialogText": "Dieser Kennzeichner wird von Dokumenten referenziert. Durch das Löschen dieses Kennzeichners werden alle Referenzen entfernt. Fortfahren?",
"@editLabelPageDeletionDialogText": {},
"errorMessageAcknowledgeTasksError": "Dateiaufgabe konnte nicht verworfen werden.",
"@errorMessageAcknowledgeTasksError": {},
"errorMessageAuthenticationFailed": "Authentifizierung fehlgeschlagen, bitte versuche es erneut.",
"@errorMessageAuthenticationFailed": {},
"errorMessageAutocompleteQueryError": "Beim automatischen Vervollständigen ist ein Fehler aufgetreten.",
@@ -250,6 +252,8 @@
"@errorMessageStoragePathCreateFailed": {},
"errorMessageStoragePathLoadFailed": "Speicherpfade konnten nicht geladen werden.",
"@errorMessageStoragePathLoadFailed": {},
"errorMessageSuggestionsQueryError": "Vorschläge konnten nicht geladen werden.",
"@errorMessageSuggestionsQueryError": {},
"errorMessageTagCreateFailed": "Tag konnte nicht erstellt werden, bitte versuche es erneut.",
"@errorMessageTagCreateFailed": {},
"errorMessageTagLoadFailed": "Tags konnten nicht geladen werden.",

View File

@@ -194,6 +194,8 @@
"@editLabelPageConfirmDeletionDialogTitle": {},
"editLabelPageDeletionDialogText": "This label contains references to other documents. By deleting this label, all references will be removed. Continue?",
"@editLabelPageDeletionDialogText": {},
"errorMessageAcknowledgeTasksError": "Could not acknowledge tasks.",
"@errorMessageAcknowledgeTasksError": {},
"errorMessageAuthenticationFailed": "Authentication failed, please try again.",
"@errorMessageAuthenticationFailed": {},
"errorMessageAutocompleteQueryError": "An error ocurred while trying to autocomplete your query.",
@@ -250,6 +252,8 @@
"@errorMessageStoragePathCreateFailed": {},
"errorMessageStoragePathLoadFailed": "Could not load storage paths.",
"@errorMessageStoragePathLoadFailed": {},
"errorMessageSuggestionsQueryError": "Could not load suggestions.",
"@errorMessageSuggestionsQueryError": {},
"errorMessageTagCreateFailed": "Could not create tag, please try again.",
"@errorMessageTagCreateFailed": {},
"errorMessageTagLoadFailed": "Could not load tags.",

View File

@@ -14,7 +14,6 @@ import 'package:paperless_api/paperless_api.dart';
import 'package:paperless_mobile/core/bloc/bloc_changes_observer.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/interceptor/dio_http_error_interceptor.dart';
import 'package:paperless_mobile/core/interceptor/language_header.interceptor.dart';
import 'package:paperless_mobile/core/repository/impl/correspondent_repository_impl.dart';
import 'package:paperless_mobile/core/repository/impl/document_type_repository_impl.dart';
@@ -27,7 +26,7 @@ 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/storage_path_repository_state.dart';
import 'package:paperless_mobile/core/repository/state/impl/tag_repository_state.dart';
import 'package:paperless_mobile/core/security/authentication_aware_dio_manager.dart';
import 'package:paperless_mobile/core/security/session_manager.dart';
import 'package:paperless_mobile/core/service/connectivity_status_service.dart';
import 'package:paperless_mobile/core/service/dio_file_service.dart';
import 'package:paperless_mobile/core/service/file_service.dart';
@@ -46,7 +45,6 @@ import 'package:paperless_mobile/features/sharing/share_intent_queue.dart';
import 'package:paperless_mobile/features/tasks/cubit/task_status_cubit.dart';
import 'package:paperless_mobile/generated/l10n.dart';
import 'package:path_provider/path_provider.dart';
import 'package:pretty_dio_logger/pretty_dio_logger.dart';
import 'package:provider/provider.dart';
import 'package:receive_sharing_intent/receive_sharing_intent.dart';
@@ -55,7 +53,6 @@ void main() async {
final widgetsBinding = WidgetsFlutterBinding.ensureInitialized();
await findSystemLocale();
await LocalNotificationService.instance.initialize();
// Initialize External dependencies
final connectivity = Connectivity();
@@ -78,27 +75,18 @@ void main() async {
final languageHeaderInterceptor = LanguageHeaderInterceptor(
appSettingsCubit.state.preferredLocaleSubtag,
);
// Required for self signed client certificates
final dioWrapper = AuthenticationAwareDioManager([
DioHttpErrorInterceptor(),
PrettyDioLogger(
compact: true,
responseBody: false,
responseHeader: false,
request: false,
requestBody: false,
requestHeader: false,
),
languageHeaderInterceptor,
]);
// Manages security context, required for self signed client certificates
final sessionManager = SessionManager([languageHeaderInterceptor]);
// Initialize Paperless APIs
final authApi = PaperlessAuthenticationApiImpl(dioWrapper.client);
final documentsApi = PaperlessDocumentsApiImpl(dioWrapper.client);
final labelsApi = PaperlessLabelApiImpl(dioWrapper.client);
final statsApi = PaperlessServerStatsApiImpl(dioWrapper.client);
final savedViewsApi = PaperlessSavedViewsApiImpl(dioWrapper.client);
final tasksApi = PaperlessTasksApiImpl(dioWrapper.client);
final authApi = PaperlessAuthenticationApiImpl(sessionManager.client);
final documentsApi = PaperlessDocumentsApiImpl(sessionManager.client);
final labelsApi = PaperlessLabelApiImpl(sessionManager.client);
final statsApi = PaperlessServerStatsApiImpl(sessionManager.client);
final savedViewsApi = PaperlessSavedViewsApiImpl(sessionManager.client);
final tasksApi = PaperlessTasksApiImpl(
sessionManager.client,
);
// Initialize Blocs/Cubits
final connectivityCubit = ConnectivityCubit(connectivityStatusService);
@@ -119,20 +107,23 @@ void main() async {
final authCubit = AuthenticationCubit(
localAuthService,
authApi,
dioWrapper,
sessionManager,
);
await authCubit
.restoreSessionState(appSettingsCubit.state.isLocalAuthenticationEnabled);
if (authCubit.state.isAuthenticated) {
final auth = authCubit.state.authentication!;
dioWrapper.updateSettings(
sessionManager.updateSettings(
baseUrl: auth.serverUrl,
authToken: auth.token,
clientCertificate: auth.clientCertificate,
);
}
final localNotificationService = LocalNotificationService();
await localNotificationService.initialize();
//Update language header in interceptor on language change.
appSettingsCubit.stream.listen((event) => languageHeaderInterceptor
.preferredLocaleSubtag = event.preferredLocaleSubtag);
@@ -149,7 +140,7 @@ void main() async {
create: (context) => cm.CacheManager(
cm.Config(
'cacheKey',
fileService: DioFileService(dioWrapper.client),
fileService: DioFileService(sessionManager.client),
),
),
),
@@ -157,6 +148,9 @@ void main() async {
Provider<ConnectivityStatusService>.value(
value: connectivityStatusService,
),
Provider<LocalNotificationService>.value(
value: localNotificationService,
),
],
child: MultiRepositoryProvider(
providers: [
@@ -221,6 +215,7 @@ class _PaperlessMobileEntrypointState extends State<PaperlessMobileEntrypoint> {
vertical: 16.0,
),
),
materialTapTargetSize: MaterialTapTargetSize.shrinkWrap,
chipTheme: ChipThemeData(
backgroundColor: Colors.lightGreen[50],
),
@@ -242,6 +237,7 @@ class _PaperlessMobileEntrypointState extends State<PaperlessMobileEntrypoint> {
vertical: 16.0,
),
),
materialTapTargetSize: MaterialTapTargetSize.shrinkWrap,
chipTheme: ChipThemeData(
backgroundColor: Colors.green[900],
),
@@ -252,7 +248,9 @@ class _PaperlessMobileEntrypointState extends State<PaperlessMobileEntrypoint> {
return MultiBlocProvider(
providers: [
BlocProvider(
create: (context) => PaperlessServerInformationCubit(context.read()),
create: (context) => PaperlessServerInformationCubit(
context.read<PaperlessServerStatsApi>(),
),
),
],
child: BlocBuilder<ApplicationSettingsCubit, ApplicationSettingsState>(
@@ -260,14 +258,8 @@ class _PaperlessMobileEntrypointState extends State<PaperlessMobileEntrypoint> {
return MaterialApp(
debugShowCheckedModeBanner: true,
title: "Paperless Mobile",
theme: _lightTheme.copyWith(
listTileTheme: _lightTheme.listTileTheme
.copyWith(tileColor: Colors.transparent),
),
darkTheme: _darkTheme.copyWith(
listTileTheme: _darkTheme.listTileTheme
.copyWith(tileColor: Colors.transparent),
),
theme: _lightTheme,
darkTheme: _darkTheme,
themeMode: settings.preferredThemeMode,
supportedLocales: S.delegate.supportedLocales,
locale: Locale.fromSubtags(

View File

@@ -131,6 +131,6 @@ class DocumentModel extends Equatable {
archiveSerialNumber,
originalFileName,
archivedFileName,
storagePath
storagePath,
];
}

View File

@@ -0,0 +1,45 @@
import 'package:json_annotation/json_annotation.dart';
part 'field_suggestions.g.dart';
@JsonSerializable(fieldRename: FieldRename.snake)
class FieldSuggestions {
final Iterable<int> correspondents;
final Iterable<int> tags;
final Iterable<int> documentTypes;
final Iterable<int> storagePaths;
final Iterable<DateTime> dates;
const FieldSuggestions({
this.correspondents = const [],
this.tags = const [],
this.documentTypes = const [],
this.storagePaths = const [],
this.dates = const [],
});
bool get hasSuggestedCorrespondents => correspondents.isNotEmpty;
bool get hasSuggestedTags => tags.isNotEmpty;
bool get hasSuggestedDocumentTypes => documentTypes.isNotEmpty;
bool get hasSuggestedStoragePaths => storagePaths.isNotEmpty;
bool get hasSuggestedDates => dates.isNotEmpty;
bool get hasSuggestions =>
hasSuggestedCorrespondents ||
hasSuggestedDates ||
hasSuggestedTags ||
hasSuggestedStoragePaths ||
hasSuggestedDocumentTypes;
int get suggestionsCount =>
(correspondents.isNotEmpty ? 1 : 0) +
(tags.isNotEmpty ? 1 : 0) +
(documentTypes.isNotEmpty ? 1 : 0) +
(storagePaths.isNotEmpty ? 1 : 0) +
(dates.isNotEmpty ? 1 : 0);
factory FieldSuggestions.fromJson(Map<String, dynamic> json) =>
_$FieldSuggestionsFromJson(json);
Map<String, dynamic> toJson() => _$FieldSuggestionsToJson(this);
}

View File

@@ -0,0 +1,33 @@
// GENERATED CODE - DO NOT MODIFY BY HAND
part of 'field_suggestions.dart';
// **************************************************************************
// JsonSerializableGenerator
// **************************************************************************
FieldSuggestions _$FieldSuggestionsFromJson(Map<String, dynamic> json) =>
FieldSuggestions(
correspondents:
(json['correspondents'] as List<dynamic>?)?.map((e) => e as int) ??
const [],
tags: (json['tags'] as List<dynamic>?)?.map((e) => e as int) ?? const [],
documentTypes:
(json['document_types'] as List<dynamic>?)?.map((e) => e as int) ??
const [],
storagePaths:
(json['storage_paths'] as List<dynamic>?)?.map((e) => e as int) ??
const [],
dates: (json['dates'] as List<dynamic>?)
?.map((e) => DateTime.parse(e as String)) ??
const [],
);
Map<String, dynamic> _$FieldSuggestionsToJson(FieldSuggestions instance) =>
<String, dynamic>{
'correspondents': instance.correspondents.toList(),
'tags': instance.tags.toList(),
'document_types': instance.documentTypes.toList(),
'storage_paths': instance.storagePaths.toList(),
'dates': instance.dates.map((e) => e.toIso8601String()).toList(),
};

View File

@@ -346,15 +346,17 @@ class FilterRule with EquatableMixin {
);
}
//Join values of all extended filter rules
final FilterRule extendedFilterRule = filterRules
.where((r) => r.ruleType == extendedRule)
.reduce((previousValue, element) => previousValue.copyWith(
value: previousValue.value! + element.value!,
));
filterRules
..removeWhere((element) => element.ruleType == extendedRule)
..add(extendedFilterRule);
//Join values of all extended filter rules if exist
if (filterRules.isNotEmpty) {
final FilterRule extendedFilterRule = filterRules
.where((r) => r.ruleType == extendedRule)
.reduce((previousValue, element) => previousValue.copyWith(
value: previousValue.value! + element.value!,
));
filterRules
..removeWhere((element) => element.ruleType == extendedRule)
..add(extendedFilterRule);
}
return filterRules;
}

View File

@@ -24,3 +24,4 @@ export 'saved_view_model.dart';
export 'similar_document_model.dart';
export 'task/task.dart';
export 'task/task_status.dart';
export 'field_suggestions.dart';

View File

@@ -43,6 +43,7 @@ enum ErrorCode {
deviceOffline,
serverUnreachable,
similarQueryError,
suggestionsQueryError,
autocompleteQueryError,
storagePathLoadFailed,
storagePathCreateFailed,
@@ -51,5 +52,6 @@ enum ErrorCode {
deleteSavedViewError,
requestTimedOut,
unsupportedFileFormat,
missingClientCertificate;
missingClientCertificate,
acknowledgeTasksError;
}

View File

@@ -1,3 +1,5 @@
import 'package:paperless_api/src/request_utils.dart';
class PaperlessServerInformationModel {
static const String versionHeader = 'x-version';
static const String apiVersionHeader = 'x-api-version';
@@ -13,4 +15,9 @@ class PaperlessServerInformationModel {
this.version = 'unknown',
this.apiVersion = 1,
});
int compareToOtherVersion(String? other) {
return getExtendedVersionNumber(version ?? '0.0.0')
.compareTo(getExtendedVersionNumber(other ?? '0.0.0'));
}
}

View File

@@ -17,7 +17,7 @@ class Task extends Equatable {
final String? result;
final bool acknowledged;
@JsonKey(fromJson: tryParseNullable)
final int? relatedDocumentId;
final int? relatedDocument;
const Task({
required this.id,
@@ -28,7 +28,7 @@ class Task extends Equatable {
this.type,
this.status,
this.acknowledged = false,
this.relatedDocumentId,
this.relatedDocument,
this.result,
});
@@ -47,6 +47,32 @@ class Task extends Equatable {
status,
result,
acknowledged,
relatedDocumentId,
relatedDocument,
];
Task copyWith({
int? id,
String? taskId,
String? taskFileName,
DateTime? dateCreated,
DateTime? dateDone,
String? type,
TaskStatus? status,
String? result,
bool? acknowledged,
int? relatedDocument,
}) {
return Task(
id: id ?? this.id,
taskId: taskId ?? this.taskId,
dateCreated: dateCreated ?? this.dateCreated,
acknowledged: acknowledged ?? this.acknowledged,
dateDone: dateDone ?? this.dateDone,
relatedDocument: relatedDocument ?? this.relatedDocument,
result: result ?? this.result,
status: status ?? this.status,
taskFileName: taskFileName ?? this.taskFileName,
type: type ?? this.type,
);
}
}

View File

@@ -17,8 +17,7 @@ Task _$TaskFromJson(Map<String, dynamic> json) => Task(
type: json['type'] as String?,
status: $enumDecodeNullable(_$TaskStatusEnumMap, json['status']),
acknowledged: json['acknowledged'] as bool? ?? false,
relatedDocumentId:
tryParseNullable(json['related_document_id'] as String?),
relatedDocument: tryParseNullable(json['related_document'] as String?),
result: json['result'] as String?,
);
@@ -32,7 +31,7 @@ Map<String, dynamic> _$TaskToJson(Task instance) => <String, dynamic>{
'status': _$TaskStatusEnumMap[instance.status],
'result': instance.result,
'acknowledged': instance.acknowledged,
'related_document_id': instance.relatedDocumentId,
'related_document': instance.relatedDocument,
};
const _$TaskStatusEnumMap = {

View File

@@ -1,11 +1,6 @@
import 'dart:typed_data';
import 'package:paperless_api/src/models/bulk_edit_model.dart';
import 'package:paperless_api/src/models/document_filter.dart';
import 'package:paperless_api/src/models/document_meta_data_model.dart';
import 'package:paperless_api/src/models/document_model.dart';
import 'package:paperless_api/src/models/paged_search_result.dart';
import 'package:paperless_api/src/models/similar_document_model.dart';
import 'package:paperless_api/src/models/models.dart';
abstract class PaperlessDocumentsApi {
/// Uploads a document using a form data request and from server version 1.11.3
@@ -21,18 +16,16 @@ abstract class PaperlessDocumentsApi {
});
Future<DocumentModel> update(DocumentModel doc);
Future<int> findNextAsn();
Future<PagedSearchResult<DocumentModel>> find(DocumentFilter filter);
Future<PagedSearchResult<DocumentModel>> findAll(DocumentFilter filter);
Future<DocumentModel?> find(int id);
Future<List<SimilarDocumentModel>> findSimilar(int docId);
Future<int> delete(DocumentModel doc);
Future<DocumentMetaData> getMetaData(DocumentModel document);
Future<Iterable<int>> bulkAction(BulkAction action);
Future<Uint8List> getPreview(int docId);
String getThumbnailUrl(int docId);
Future<DocumentModel> waitForConsumptionFinished(
String filename,
String title,
);
Future<Uint8List> download(DocumentModel document);
Future<FieldSuggestions> findSuggestions(DocumentModel document);
Future<List<String>> autocomplete(String query, [int limit = 10]);
}

View File

@@ -1,13 +1,9 @@
import 'dart:convert';
import 'dart:io';
import 'package:dio/dio.dart';
import 'package:flutter/foundation.dart';
import 'package:paperless_api/paperless_api.dart';
import 'package:paperless_api/src/constants.dart';
import 'package:paperless_api/src/converters/document_model_json_converter.dart';
import 'package:paperless_api/src/converters/similar_document_model_json_converter.dart';
import 'package:paperless_api/src/request_utils.dart';
class PaperlessDocumentsApiImpl implements PaperlessDocumentsApi {
final Dio client;
@@ -82,8 +78,11 @@ class PaperlessDocumentsApiImpl implements PaperlessDocumentsApi {
}
@override
Future<PagedSearchResult<DocumentModel>> find(DocumentFilter filter) async {
final filterParams = filter.toQueryParameters();
Future<PagedSearchResult<DocumentModel>> findAll(
DocumentFilter filter,
) async {
final filterParams = filter.toQueryParameters()
..addAll({'truncate_content': "true"});
try {
final response = await client.get(
"/api/documents/",
@@ -156,7 +155,7 @@ class PaperlessDocumentsApiImpl implements PaperlessDocumentsApi {
pageSize: 1,
);
try {
final result = await find(asnQueryFilter);
final result = await findAll(asnQueryFilter);
return result.results
.map((e) => e.archiveSerialNumber)
.firstWhere((asn) => asn != null, orElse: () => 0)! +
@@ -187,26 +186,6 @@ class PaperlessDocumentsApiImpl implements PaperlessDocumentsApi {
}
}
@override
Future<DocumentModel> waitForConsumptionFinished(
String fileName, String title) async {
PagedSearchResult<DocumentModel> results =
await find(DocumentFilter.latestDocument);
while ((results.results.isEmpty ||
(results.results[0].originalFileName != fileName &&
results.results[0].title != title))) {
//TODO: maybe implement more intelligent retry logic or find workaround for websocket authentication...
await Future.delayed(const Duration(seconds: 2));
results = await find(DocumentFilter.latestDocument);
}
try {
return results.results.first;
} on StateError {
throw const PaperlessServerException(ErrorCode.documentUploadFailed);
}
}
@override
Future<Uint8List> download(DocumentModel document) async {
try {
@@ -273,4 +252,32 @@ class PaperlessDocumentsApiImpl implements PaperlessDocumentsApi {
throw err.error;
}
}
@override
Future<FieldSuggestions> findSuggestions(DocumentModel document) async {
try {
final response =
await client.get("/api/documents/${document.id}/suggestions/");
if (response.statusCode == 200) {
return FieldSuggestions.fromJson(response.data);
}
throw const PaperlessServerException(ErrorCode.suggestionsQueryError);
} on DioError catch (err) {
throw err.error;
}
}
@override
Future<DocumentModel?> find(int id) async {
try {
final response = await client.get("/api/documents/$id/");
if (response.statusCode == 200) {
return DocumentModel.fromJson(response.data);
} else {
return null;
}
} on DioError catch (err) {
throw err.error;
}
}
}

View File

@@ -4,4 +4,6 @@ abstract class PaperlessTasksApi {
Future<Task?> find({int? id, String? taskId});
Future<Iterable<Task>> findAll([Iterable<int>? ids]);
Stream<Task> listenForTaskChanges(String taskId);
Future<Task> acknowledgeTask(Task task);
Future<Iterable<Task>> acknowledgeTasks(Iterable<Task> tasks);
}

View File

@@ -1,34 +1,48 @@
import 'package:dio/dio.dart';
import 'package:paperless_api/src/models/task/task.dart';
import 'package:paperless_api/src/models/task/task_status.dart';
import 'dart:developer';
import 'paperless_tasks_api.dart';
import 'package:dio/dio.dart';
import 'package:paperless_api/paperless_api.dart';
import 'package:paperless_api/src/request_utils.dart';
class PaperlessTasksApiImpl implements PaperlessTasksApi {
final Dio client;
final Dio _client;
const PaperlessTasksApiImpl(this.client);
PaperlessTasksApiImpl(this._client);
@override
Future<Task?> find({int? id, String? taskId}) async {
assert(id != null || taskId != null);
String url = "/api/tasks/";
if (taskId != null) {
url += "?task_id=$taskId";
} else {
url += "$id/";
assert((id != null) != (taskId != null));
if (id != null) {
return _findById(id);
} else if (taskId != null) {
return _findByTaskId(taskId);
}
return null;
}
final response = await client.get(url);
/// API response returns List with single item
Future<Task?> _findById(int id) async {
final response = await _client.get("/api/tasks/$id/");
if (response.statusCode == 200) {
return Task.fromJson(response.data);
}
return null;
}
/// API response returns List with single item
Future<Task?> _findByTaskId(String taskId) async {
final response = await _client.get("/api/tasks/?task_id=$taskId");
if (response.statusCode == 200) {
if ((response.data as List).isNotEmpty) {
return Task.fromJson((response.data as List).first);
}
}
return null;
}
@override
Future<Iterable<Task>> findAll([Iterable<int>? ids]) async {
final response = await client.get("/api/tasks/");
final response = await _client.get("/api/tasks/");
if (response.statusCode == 200) {
return (response.data as List).map((e) => Task.fromJson(e));
}
@@ -37,17 +51,39 @@ class PaperlessTasksApiImpl implements PaperlessTasksApi {
@override
Stream<Task> listenForTaskChanges(String taskId) async* {
bool isSuccess = false;
while (!isSuccess) {
bool isCompleted = false;
while (!isCompleted) {
final task = await find(taskId: taskId);
if (task == null) {
throw Exception("Task with taskId $taskId does not exist.");
}
log("Found new task: ${task.taskId}, ${task.id}, ${task.status}");
yield task;
if (task.status == TaskStatus.success) {
isSuccess = true;
if (task.status == TaskStatus.success ||
task.status == TaskStatus.failure) {
isCompleted = true;
}
await Future.delayed(const Duration(seconds: 1));
}
}
@override
Future<Task> acknowledgeTask(Task task) async {
final acknowledgedTasks = await acknowledgeTasks([task]);
return acknowledgedTasks.first.copyWith(acknowledged: true);
}
@override
Future<Iterable<Task>> acknowledgeTasks(Iterable<Task> tasks) async {
final response = await _client.post("/api/acknowledge_tasks/", data: {
'tasks': tasks.map((e) => e.id).toList(),
});
if (response.statusCode == 200) {
if (response.data['result'] != tasks.length) {
throw const PaperlessServerException(ErrorCode.acknowledgeTasksError);
}
return tasks.map((e) => e.copyWith(acknowledged: true)).toList();
}
throw const PaperlessServerException(ErrorCode.acknowledgeTasksError);
}
}

View File

@@ -633,6 +633,14 @@ packages:
url: "https://pub.dev"
source: hosted
version: "2.0.7"
flutter_staggered_grid_view:
dependency: "direct main"
description:
name: flutter_staggered_grid_view
sha256: "1312314293acceb65b92754298754801b0e1f26a1845833b740b30415bbbcf07"
url: "https://pub.dev"
source: hosted
version: "0.6.2"
flutter_svg:
dependency: "direct main"
description:
@@ -696,14 +704,6 @@ packages:
description: flutter
source: sdk
version: "0.0.0"
get_it:
dependency: transitive
description:
name: get_it
sha256: "290fde3a86072e4b37dbb03c07bec6126f0ecc28dad403c12ffe2e5a2d751ab7"
url: "https://pub.dev"
source: hosted
version: "7.2.0"
glob:
dependency: transitive
description:
@@ -784,30 +784,6 @@ packages:
url: "https://pub.dev"
source: hosted
version: "3.3.0"
infinite_scroll_pagination:
dependency: "direct main"
description:
name: infinite_scroll_pagination
sha256: "9517328f4e373f08f57dbb11c5aac5b05554142024d6b60c903f3b73476d52db"
url: "https://pub.dev"
source: hosted
version: "3.2.0"
injectable:
dependency: transitive
description:
name: injectable
sha256: "7dab7d341feb40a0590d9ff6261aea9495522005e2c6763f9161a4face916f7b"
url: "https://pub.dev"
source: hosted
version: "2.1.0"
injectable_generator:
dependency: "direct dev"
description:
name: injectable_generator
sha256: "9a3bbd2c3ba821e31ef6cea3fc535c17e3a25c74e173b6cefa05f466c8338bc8"
url: "https://pub.dev"
source: hosted
version: "2.1.3"
integration_test:
dependency: "direct dev"
description: flutter
@@ -1308,14 +1284,6 @@ packages:
url: "https://pub.dev"
source: hosted
version: "3.0.1"
recase:
dependency: transitive
description:
name: recase
sha256: e4eb4ec2dcdee52dcf99cb4ceabaffc631d7424ee55e56f280bc039737f89213
url: "https://pub.dev"
source: hosted
version: "4.1.0"
receive_sharing_intent:
dependency: "direct main"
description:
@@ -1457,14 +1425,6 @@ packages:
description: flutter
source: sdk
version: "0.0.99"
sliver_tools:
dependency: transitive
description:
name: sliver_tools
sha256: edf005f1a47c2ffa6f1e1a4f24dd99c45b8bccfff9b928d39170d36dc6fda871
url: "https://pub.dev"
source: hosted
version: "0.2.8"
source_gen:
dependency: transitive
description:

View File

@@ -57,7 +57,6 @@ dependencies:
equatable: ^2.0.3
flutter_form_builder: ^7.5.0
form_builder_validators: ^8.4.0
infinite_scroll_pagination: ^3.2.0
package_info_plus: ^1.4.3+1
font_awesome_flutter: ^10.1.0
local_auth: ^2.1.2
@@ -85,6 +84,7 @@ dependencies:
collection: ^1.17.0
device_info_plus: ^4.1.3
flutter_local_notifications: ^13.0.0
flutter_staggered_grid_view: ^0.6.2
dev_dependencies:
integration_test:
@@ -92,7 +92,6 @@ dev_dependencies:
flutter_test:
sdk: flutter
build_runner: ^2.1.11
injectable_generator: ^2.1.0
mockito: ^5.3.2
bloc_test: ^9.1.0
dependency_validator: ^3.0.0