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 ```sh
flutter pub get flutter pub get
``` ```
3. Build generated files (e.g. for injectable library) 3. Build generated files (for json_serializable etc.)
```sh ```sh
flutter packages pub run build_runner build --delete-conflicting-outputs 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:flutter_bloc/flutter_bloc.dart';
import 'package:paperless_mobile/core/model/document_processing_status.dart'; import 'package:paperless_mobile/core/model/document_processing_status.dart';
import 'package:injectable/injectable.dart';
@prod
@test
@lazySingleton
class DocumentStatusCubit extends Cubit<DocumentProcessingStatus?> { class DocumentStatusCubit extends Cubit<DocumentProcessingStatus?> {
DocumentStatusCubit() : super(null); DocumentStatusCubit() : super(null);

View File

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

View File

@@ -68,5 +68,9 @@ String translateError(BuildContext context, ErrorCode code) {
return S.of(context).errorMessageUnsupportedFileFormat; return S.of(context).errorMessageUnsupportedFileFormat;
case ErrorCode.missingClientCertificate: case ErrorCode.missingClientCertificate:
return S.of(context).errorMessageMissingClientCertificate; 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/adapter.dart';
import 'package:dio/dio.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/core/interceptor/retry_on_connection_change_interceptor.dart';
import 'package:paperless_mobile/features/login/model/client_certificate.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 Dio client;
final List<Interceptor> interceptors; final List<Interceptor> interceptors;
PaperlessServerInformationModel serverInformation;
AuthenticationAwareDioManager([this.interceptors = const []]) SessionManager([this.interceptors = const []])
: client = _initDio(interceptors); : client = _initDio(interceptors),
serverInformation = PaperlessServerInformationModel();
static Dio _initDio(List<Interceptor> interceptors) { static Dio _initDio(List<Interceptor> interceptors) {
//en- and decoded by utf8 by default //en- and decoded by utf8 by default
@@ -19,8 +24,19 @@ class AuthenticationAwareDioManager {
dio.options.responseType = ResponseType.json; dio.options.responseType = ResponseType.json;
(dio.httpClientAdapter as DefaultHttpClientAdapter).onHttpClientCreate = (dio.httpClientAdapter as DefaultHttpClientAdapter).onHttpClientCreate =
(client) => client..badCertificateCallback = (cert, host, port) => true; (client) => client..badCertificateCallback = (cert, host, port) => true;
dio.interceptors.addAll(interceptors); dio.interceptors.addAll([
dio.interceptors.add(RetryOnConnectionChangeInterceptor(dio: dio)); ...interceptors,
DioHttpErrorInterceptor(),
PrettyDioLogger(
compact: true,
responseBody: false,
responseHeader: false,
request: false,
requestBody: false,
requestHeader: false,
),
RetryOnConnectionChangeInterceptor(dio: dio)
]);
return dio; return dio;
} }
@@ -28,6 +44,7 @@ class AuthenticationAwareDioManager {
String? baseUrl, String? baseUrl,
String? authToken, String? authToken,
ClientCertificate? clientCertificate, ClientCertificate? clientCertificate,
PaperlessServerInformationModel? serverInformation,
}) { }) {
if (clientCertificate != null) { if (clientCertificate != null) {
final context = SecurityContext() final context = SecurityContext()
@@ -58,11 +75,16 @@ class AuthenticationAwareDioManager {
if (authToken != null) { if (authToken != null) {
client.options.headers.addAll({'Authorization': 'Token $authToken'}); client.options.headers.addAll({'Authorization': 'Token $authToken'});
} }
if (serverInformation != null) {
this.serverInformation = serverInformation;
}
} }
void resetSettings() { void resetSettings() {
client.httpClientAdapter = DefaultHttpClientAdapter(); client.httpClientAdapter = DefaultHttpClientAdapter();
client.options.baseUrl = ''; client.options.baseUrl = '';
client.options.headers.remove('Authorization'); 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; final PaperlessDocumentsApi _api;
DocumentDetailsCubit(this._api, DocumentModel initialDocument) DocumentDetailsCubit(this._api, DocumentModel initialDocument)
: super(DocumentDetailsState(document: initialDocument)); : super(DocumentDetailsState(document: initialDocument)) {
loadSuggestions();
}
Future<void> delete(DocumentModel document) async { Future<void> delete(DocumentModel document) async {
await _api.delete(document); 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 { Future<void> assignAsn(DocumentModel document) async {
if (document.archiveSerialNumber == null) { if (document.archiveSerialNumber == null) {
final int asn = await _api.findNextAsn(); final int asn = await _api.findNextAsn();
final updatedDocument = final updatedDocument =
await _api.update(document.copyWith(archiveSerialNumber: asn)); await _api.update(document.copyWith(archiveSerialNumber: asn));
emit(DocumentDetailsState(document: updatedDocument)); emit(state.copyWith(document: updatedDocument));
} }
} }
void replaceDocument(DocumentModel document) { 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 { class DocumentDetailsState with EquatableMixin {
final DocumentModel document; final DocumentModel document;
final bool isFullContentLoaded;
final String? fullContent;
final FieldSuggestions suggestions;
const DocumentDetailsState({ const DocumentDetailsState({
required this.document, required this.document,
this.suggestions = const FieldSuggestions(),
this.isFullContentLoaded = false,
this.fullContent,
}); });
@override @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:paperless_mobile/util.dart';
import 'package:path_provider/path_provider.dart'; import 'package:path_provider/path_provider.dart';
import 'package:share_plus/share_plus.dart'; import 'package:share_plus/share_plus.dart';
import 'package:badges/badges.dart' as b;
class DocumentDetailsPage extends StatefulWidget { class DocumentDetailsPage extends StatefulWidget {
final bool allowEdit; final bool allowEdit;
@@ -63,9 +64,21 @@ class _DocumentDetailsPageState extends State<DocumentDetailsPage> {
if (!connectivityState.isConnected) { if (!connectivityState.isConnected) {
return Container(); return Container();
} }
return FloatingActionButton( return b.Badge(
child: const Icon(Icons.edit), position: b.BadgePosition.topEnd(top: -12, end: -6),
onPressed: () => _onEdit(state.document), 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( _buildDocumentContentView(
state.document, state.document,
widget.titleAndContentQueryString, widget.titleAndContentQueryString,
state,
), ),
_buildDocumentMetaDataView( _buildDocumentMetaDataView(
state.document, state.document,
@@ -217,7 +231,9 @@ class _DocumentDetailsPageState extends State<DocumentDetailsPage> {
listener: (context, state) { listener: (context, state) {
cubit.replaceDocument(state.document); cubit.replaceDocument(state.document);
}, },
child: const DocumentEditPage(), child: DocumentEditPage(
suggestions: cubit.state.suggestions,
),
), ),
), ),
maintainState: true, maintainState: true,
@@ -303,14 +319,30 @@ class _DocumentDetailsPageState extends State<DocumentDetailsPage> {
} }
} }
Widget _buildDocumentContentView(DocumentModel document, String? match) { Widget _buildDocumentContentView(
return SingleChildScrollView( DocumentModel document,
child: HighlightedText( String? match,
text: document.content ?? "", DocumentDetailsState state,
highlights: match == null ? [] : match.split(" "), ) {
style: Theme.of(context).textTheme.bodyMedium, return ListView(
caseSensitive: false, 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); ).paddedOnly(top: 8);
} }

View File

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

View File

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

View File

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

View File

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

View File

@@ -3,6 +3,7 @@ import 'dart:async';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:flutter_form_builder/flutter_form_builder.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:form_builder_validators/form_builder_validators.dart';
import 'package:intl/intl.dart'; import 'package:intl/intl.dart';
import 'package:paperless_api/paperless_api.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_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_document_type_page.dart';
import 'package:paperless_mobile/features/edit_label/view/impl/add_storage_path_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/tags/view/widgets/tags_form_field.dart';
import 'package:paperless_mobile/features/labels/view/widgets/label_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/generated/l10n.dart';
import 'package:paperless_mobile/util.dart'; import 'package:paperless_mobile/util.dart';
class DocumentEditPage extends StatefulWidget { class DocumentEditPage extends StatefulWidget {
final FieldSuggestions suggestions;
const DocumentEditPage({ const DocumentEditPage({
Key? key, Key? key,
required this.suggestions,
}) : super(key: key); }) : super(key: key);
@override @override
@@ -74,14 +79,17 @@ class _DocumentEditPageState extends State<DocumentEditPage> {
_buildTitleFormField(state.document.title).padded(), _buildTitleFormField(state.document.title).padded(),
_buildCreatedAtFormField(state.document.created).padded(), _buildCreatedAtFormField(state.document.created).padded(),
_buildDocumentTypeFormField( _buildDocumentTypeFormField(
state.document.documentType, state.documentTypes) state.document.documentType,
.padded(), state.documentTypes,
).padded(),
_buildCorrespondentFormField( _buildCorrespondentFormField(
state.document.correspondent, state.correspondents) state.document.correspondent,
.padded(), state.correspondents,
).padded(),
_buildStoragePathFormField( _buildStoragePathFormField(
state.document.storagePath, state.storagePaths) state.document.storagePath,
.padded(), state.storagePaths,
).padded(),
TagFormField( TagFormField(
initialValue: initialValue:
IdsTagsQuery.included(state.document.tags.toList()), IdsTagsQuery.included(state.document.tags.toList()),
@@ -99,58 +107,101 @@ class _DocumentEditPageState extends State<DocumentEditPage> {
} }
Widget _buildStoragePathFormField( Widget _buildStoragePathFormField(
int? initialId, Map<int, StoragePath> options) { int? initialId,
return LabelFormField<StoragePath>( Map<int, StoragePath> options,
notAssignedSelectable: false, ) {
formBuilderState: _formKey.currentState, return Column(
labelCreationWidgetBuilder: (initialValue) => RepositoryProvider( children: [
create: (context) => context LabelFormField<StoragePath>(
.read<LabelRepository<StoragePath, StoragePathRepositoryState>>(), notAssignedSelectable: false,
child: AddStoragePathPage(initalValue: initialValue), formBuilderState: _formKey.currentState,
), labelCreationWidgetBuilder: (initialValue) => RepositoryProvider(
textFieldLabel: S.of(context).documentStoragePathPropertyLabel, create: (context) => context.read<
labelOptions: options, LabelRepository<StoragePath, StoragePathRepositoryState>>(),
initialValue: IdQueryParameter.fromId(initialId), child: AddStoragePathPage(initalValue: initialValue),
name: fkStoragePath, ),
prefixIcon: const Icon(Icons.folder_outlined), 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( Widget _buildCorrespondentFormField(
int? initialId, Map<int, Correspondent> options) { int? initialId, Map<int, Correspondent> options) {
return LabelFormField<Correspondent>( return Column(
notAssignedSelectable: false, children: [
formBuilderState: _formKey.currentState, LabelFormField<Correspondent>(
labelCreationWidgetBuilder: (initialValue) => RepositoryProvider( notAssignedSelectable: false,
create: (context) => context.read< formBuilderState: _formKey.currentState,
LabelRepository<Correspondent, CorrespondentRepositoryState>>(), labelCreationWidgetBuilder: (initialValue) => RepositoryProvider(
child: AddCorrespondentPage(initialName: initialValue), create: (context) => context.read<
), LabelRepository<Correspondent, CorrespondentRepositoryState>>(),
textFieldLabel: S.of(context).documentCorrespondentPropertyLabel, child: AddCorrespondentPage(initialName: initialValue),
labelOptions: options, ),
initialValue: IdQueryParameter.fromId(initialId), textFieldLabel: S.of(context).documentCorrespondentPropertyLabel,
name: fkCorrespondent, labelOptions: options,
prefixIcon: const Icon(Icons.person_outlined), 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( Widget _buildDocumentTypeFormField(
int? initialId, Map<int, DocumentType> options) { int? initialId,
return LabelFormField<DocumentType>( Map<int, DocumentType> options,
notAssignedSelectable: false, ) {
formBuilderState: _formKey.currentState, return Column(
labelCreationWidgetBuilder: (currentInput) => RepositoryProvider( children: [
create: (context) => context LabelFormField<DocumentType>(
.read<LabelRepository<DocumentType, DocumentTypeRepositoryState>>(), notAssignedSelectable: false,
child: AddDocumentTypePage( formBuilderState: _formKey.currentState,
initialName: currentInput, 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),
), ),
), if (widget.suggestions.hasSuggestedDocumentTypes)
textFieldLabel: S.of(context).documentDocumentTypePropertyLabel, _buildSuggestionsSkeleton<int>(
initialValue: IdQueryParameter.fromId(initialId), suggestions: widget.suggestions.documentTypes,
labelOptions: options, itemBuilder: (context, itemData) => ActionChip(
name: fkDocumentType, label: Text(options[itemData]!.name),
prefixIcon: const Icon(Icons.description_outlined), onPressed: () => _formKey.currentState?.fields[fkDocumentType]
?.didChange(IdQueryParameter.fromId(itemData)),
),
),
],
); );
} }
@@ -198,16 +249,56 @@ class _DocumentEditPageState extends State<DocumentEditPage> {
} }
Widget _buildCreatedAtFormField(DateTime? initialCreatedAtDate) { Widget _buildCreatedAtFormField(DateTime? initialCreatedAtDate) {
return FormBuilderDateTimePicker( return Column(
inputType: InputType.date, crossAxisAlignment: CrossAxisAlignment.start,
name: fkCreatedDate, children: [
decoration: InputDecoration( FormBuilderDateTimePicker(
prefixIcon: const Icon(Icons.calendar_month_outlined), inputType: InputType.date,
label: Text(S.of(context).documentCreatedPropertyLabel), name: fkCreatedDate,
), decoration: InputDecoration(
initialValue: initialCreatedAtDate, prefixIcon: const Icon(Icons.calendar_month_outlined),
format: DateFormat("dd. MMMM yyyy"), //TODO: Localized date format label: Text(S.of(context).documentCreatedPropertyLabel),
initialEntryMode: DatePickerEntryMode.calendar, ),
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:collection/collection.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:infinite_scroll_pagination/infinite_scroll_pagination.dart';
import 'package:paperless_api/paperless_api.dart'; import 'package:paperless_api/paperless_api.dart';
import 'package:paperless_mobile/core/bloc/connectivity_cubit.dart'; import 'package:paperless_mobile/core/bloc/connectivity_cubit.dart';
import 'package:paperless_mobile/core/repository/provider/label_repositories_provider.dart'; import 'package:paperless_mobile/core/repository/provider/label_repositories_provider.dart';
import 'package:paperless_mobile/extensions/flutter_extensions.dart';
import 'package:paperless_mobile/features/document_details/bloc/document_details_cubit.dart'; import 'package:paperless_mobile/features/document_details/bloc/document_details_cubit.dart';
import 'package:paperless_mobile/features/document_details/view/pages/document_details_page.dart'; import 'package:paperless_mobile/features/document_details/view/pages/document_details_page.dart';
import 'package:paperless_mobile/features/documents/bloc/documents_cubit.dart'; import 'package:paperless_mobile/features/documents/bloc/documents_cubit.dart';
import 'package:paperless_mobile/features/documents/bloc/documents_state.dart'; import 'package:paperless_mobile/features/documents/bloc/documents_state.dart';
import 'package:paperless_mobile/features/documents/view/widgets/documents_empty_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/adaptive_documents_view.dart';
import 'package:paperless_mobile/features/documents/view/widgets/list/document_list.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/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/documents/view/widgets/sort_documents_button.dart';
import 'package:paperless_mobile/features/home/view/widget/info_drawer.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/labels/bloc/providers/labels_bloc_provider.dart';
import 'package:paperless_mobile/features/login/bloc/authentication_cubit.dart'; import 'package:paperless_mobile/features/login/bloc/authentication_cubit.dart';
import 'package:paperless_mobile/features/saved_view/cubit/saved_view_cubit.dart'; import 'package:paperless_mobile/features/saved_view/cubit/saved_view_cubit.dart';
import 'package:paperless_mobile/features/saved_view/view/saved_view_selection_widget.dart';
import 'package:paperless_mobile/features/settings/bloc/application_settings_cubit.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/application_settings_state.dart';
import 'package:paperless_mobile/features/settings/model/view_type.dart'; import 'package:paperless_mobile/features/settings/model/view_type.dart';
import 'package:paperless_mobile/features/tasks/cubit/task_status_cubit.dart';
import 'package:paperless_mobile/generated/l10n.dart';
import 'package:paperless_mobile/util.dart'; import 'package:paperless_mobile/util.dart';
class DocumentFilterIntent { class DocumentFilterIntent {
@@ -42,9 +45,11 @@ class DocumentsPage extends StatefulWidget {
} }
class _DocumentsPageState extends State<DocumentsPage> { class _DocumentsPageState extends State<DocumentsPage> {
final _pagingController = PagingController<int, DocumentModel>( final ScrollController _scrollController = ScrollController();
firstPageKey: 1, double _offset = 0;
); double _last = 0;
static const double _savedViewWidgetHeight = 78 + 16;
@override @override
void initState() { void initState() {
@@ -55,12 +60,36 @@ class _DocumentsPageState extends State<DocumentsPage> {
} on PaperlessServerException catch (error, stackTrace) { } on PaperlessServerException catch (error, stackTrace) {
showErrorMessage(context, 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 @override
void dispose() { void dispose() {
_pagingController.dispose(); _scrollController.dispose();
super.dispose(); super.dispose();
} }
@@ -78,6 +107,7 @@ class _DocumentsPageState extends State<DocumentsPage> {
} }
}, },
builder: (context, connectivityState) { builder: (context, connectivityState) {
const linearProgressIndicatorHeight = 4.0;
return Scaffold( return Scaffold(
drawer: BlocProvider.value( drawer: BlocProvider.value(
value: context.read<AuthenticationCubit>(), value: context.read<AuthenticationCubit>(),
@@ -85,16 +115,65 @@ class _DocumentsPageState extends State<DocumentsPage> {
afterInboxClosed: () => context.read<DocumentsCubit>().reload(), 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>( floatingActionButton: BlocBuilder<DocumentsCubit, DocumentsState>(
builder: (context, state) { builder: (context, state) {
final appliedFiltersCount = state.filter.appliedFiltersCount; final appliedFiltersCount = state.filter.appliedFiltersCount;
return Badge.count( return b.Badge(
//TODO: Wait for stable version of m3, then use AlignmentDirectional.topEnd position: b.BadgePosition.topEnd(top: -12, end: -6),
alignment: const AlignmentDirectional(44, -4), showBadge: appliedFiltersCount > 0,
isLabelVisible: appliedFiltersCount > 0, badgeContent: Text(
count: state.filter.appliedFiltersCount, '$appliedFiltersCount',
backgroundColor: Colors.red, style: const TextStyle(
textColor: Colors.white, color: Colors.white,
),
),
animationType: b.BadgeAnimationType.fade,
badgeColor: Theme.of(context).colorScheme.error,
child: FloatingActionButton( child: FloatingActionButton(
child: const Icon(Icons.filter_alt_outlined), child: const Icon(Icons.filter_alt_outlined),
onPressed: _openDocumentFilter, onPressed: _openDocumentFilter,
@@ -103,12 +182,71 @@ class _DocumentsPageState extends State<DocumentsPage> {
}, },
), ),
resizeToAvoidBottomInset: true, 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 { void _openDocumentFilter() async {
final draggableSheetController = DraggableScrollableController(); final draggableSheetController = DraggableScrollableController();
final filterIntent = await showModalBottomSheet<DocumentFilterIntent>( 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) { Widget _buildBody(ConnectivityState connectivityState) {
final isConnected = connectivityState == ConnectivityState.connected; final isConnected = connectivityState == ConnectivityState.connected;
return BlocBuilder<ApplicationSettingsCubit, ApplicationSettingsState>( return BlocBuilder<ApplicationSettingsCubit, ApplicationSettingsState>(
builder: (context, settings) { builder: (context, settings) {
return BlocBuilder<DocumentsCubit, DocumentsState>( return BlocBuilder<DocumentsCubit, DocumentsState>(
buildWhen: (previous, current) => !const ListEquality() buildWhen: (previous, current) =>
.equals(previous.documents, current.documents), !const ListEquality()
.equals(previous.documents, current.documents) ||
previous.selectedIds != current.selectedIds,
builder: (context, state) { builder: (context, state) {
// Some ugly tricks to make it work with bloc, update pageController // 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) { if (state.hasLoaded && state.documents.isEmpty) {
child = SliverToBoxAdapter( return DocumentsEmptyState(
child: DocumentsEmptyState( state: state,
state: state, onReset: () {
onReset: () { context.read<DocumentsCubit>().resetFilter();
context.read<DocumentsCubit>().resetFilter(); context.read<DocumentsCubit>().unselectView();
context.read<DocumentsCubit>().unselectView(); },
},
),
); );
} }
return RefreshIndicator( return AdaptiveDocumentsView(
onRefresh: _onRefresh, viewType: settings.preferredViewType,
notificationPredicate: (_) => isConnected, state: state,
child: CustomScrollView( scrollController: _scrollController,
slivers: [ onTap: _openDetails,
DocumentsPageAppBar( onSelected: _onSelected,
isOffline: connectivityState != ConnectivityState.connected, hasInternetConnection: isConnected,
actions: [ onTagSelected: _addTagToFilter,
const SortDocumentsButton(), onCorrespondentSelected: _addCorrespondentToFilter,
IconButton( onDocumentTypeSelected: _addDocumentTypeToFilter,
icon: Icon( onStoragePathSelected: _addStoragePathToFilter,
settings.preferredViewType == ViewType.grid pageLoadingWidget: const NewItemsLoadingWidget(),
? Icons.list beforeItems: const SizedBox(height: _savedViewWidgetHeight),
: Icons.grid_view,
),
onPressed: () => context
.read<ApplicationSettingsCubit>()
.setViewType(
settings.preferredViewType.toggle(),
),
),
],
),
child,
],
),
); );
}, },
); );
@@ -355,15 +450,9 @@ class _DocumentsPageState extends State<DocumentsPage> {
} }
} }
Future<void> _loadNewPage(int pageKey) async { Future<void> _loadNewPage() async {
final documentsCubit = context.read<DocumentsCubit>();
final pageCount = documentsCubit.state
.inferPageCount(pageSize: documentsCubit.state.filter.pageSize);
if (pageCount <= pageKey + 1) {
_pagingController.nextPageKey = null;
}
try { try {
await documentsCubit.loadMore(); await context.read<DocumentsCubit>().loadMore();
} on PaperlessServerException catch (error, stackTrace) { } on PaperlessServerException catch (error, stackTrace) {
showErrorMessage(context, error, stackTrace); showErrorMessage(context, error, stackTrace);
} }
@@ -376,8 +465,8 @@ class _DocumentsPageState extends State<DocumentsPage> {
Future<void> _onRefresh() async { Future<void> _onRefresh() async {
try { try {
// We do not await here on purpose so we can show a linear progress indicator below the app bar. // We do not await here on purpose so we can show a linear progress indicator below the app bar.
await context.read<DocumentsCubit>().reload(); context.read<DocumentsCubit>().reload();
await context.read<SavedViewCubit>().reload(); context.read<SavedViewCubit>().reload();
} on PaperlessServerException catch (error, stackTrace) { } on PaperlessServerException catch (error, stackTrace) {
showErrorMessage(context, 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 void Function(DocumentModel) onSelected;
final bool isAtLeastOneSelected; final bool isAtLeastOneSelected;
final bool Function(int tagId) isTagSelectedPredicate; final bool Function(int tagId) isTagSelectedPredicate;
final void Function(int tagId) onTagSelected; final void Function(int tagId)? onTagSelected;
const DocumentGridItem({ const DocumentGridItem({
Key? key, Key? key,
@@ -57,9 +57,11 @@ class DocumentGridItem extends StatelessWidget {
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
CorrespondentWidget( CorrespondentWidget(
correspondentId: document.correspondent), correspondentId: document.correspondent,
),
DocumentTypeWidget( DocumentTypeWidget(
documentTypeId: document.documentType), documentTypeId: document.documentType,
),
Text( Text(
document.title, document.title,
maxLines: document.tags.isEmpty ? 3 : 2, 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) { Widget build(BuildContext context) {
return SizedBox( return SizedBox(
child: ListTile( child: ListTile(
trailing: Text("${document.id}"),
dense: true, dense: true,
selected: isSelected, selected: isSelected,
onTap: () => _onTap(), 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(); 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 @override
void initState() { void initState() {
super.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); _initializeData(context);
WidgetsBinding.instance.addPostFrameCallback((timeStamp) { WidgetsBinding.instance.addPostFrameCallback((timeStamp) {
_listenForReceivedFiles(); _listenForReceivedFiles();
@@ -195,7 +155,14 @@ class _HomePageState extends State<HomePage> {
}, },
), ),
BlocListener<TaskStatusCubit, TaskStatusState>( 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( child: Scaffold(

View File

@@ -1,5 +1,4 @@
import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:injectable/injectable.dart';
import 'package:paperless_api/paperless_api.dart'; import 'package:paperless_api/paperless_api.dart';
import 'package:paperless_mobile/core/repository/label_repository.dart'; import 'package:paperless_mobile/core/repository/label_repository.dart';
import 'package:paperless_mobile/core/repository/state/impl/tag_repository_state.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 final inboxDocuments = await _documentsApi
.find(DocumentFilter( .findAll(DocumentFilter(
tags: AnyAssignedTagsQuery(tagIds: inboxTags), tags: AnyAssignedTagsQuery(tagIds: inboxTags),
sortField: SortField.added, sortField: SortField.added,
)) ))

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,8 +1,6 @@
import 'package:injectable/injectable.dart';
import 'package:local_auth/local_auth.dart'; import 'package:local_auth/local_auth.dart';
import 'package:paperless_mobile/core/store/local_vault.dart'; import 'package:paperless_mobile/core/store/local_vault.dart';
@lazySingleton
class LocalAuthenticationService { class LocalAuthenticationService {
final LocalVault localStore; final LocalVault localStore;
final LocalAuthentication localAuthentication; 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 = final FlutterLocalNotificationsPlugin _plugin =
FlutterLocalNotificationsPlugin(); FlutterLocalNotificationsPlugin();
LocalNotificationService._(); LocalNotificationService();
static final LocalNotificationService instance = LocalNotificationService._();
Future<void> initialize() async { Future<void> initialize() async {
const AndroidInitializationSettings initializationSettingsAndroid = const AndroidInitializationSettings initializationSettingsAndroid =
@@ -71,7 +69,7 @@ class LocalNotificationService {
body = task.taskFileName; body = task.taskFileName;
timestampMillis = task.dateDone!.millisecondsSinceEpoch; timestampMillis = task.dateDone!.millisecondsSinceEpoch;
payload = CreateDocumentSuccessNotificationResponsePayload( payload = CreateDocumentSuccessNotificationResponsePayload(
task.relatedDocumentId!, task.relatedDocument!,
); );
break; break;
default: default:

View File

@@ -4,6 +4,7 @@ import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:paperless_api/paperless_api.dart'; import 'package:paperless_api/paperless_api.dart';
import 'package:paperless_mobile/core/bloc/connectivity_cubit.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_cubit.dart';
import 'package:paperless_mobile/features/documents/bloc/documents_state.dart'; import 'package:paperless_mobile/features/documents/bloc/documents_state.dart';
import 'package:paperless_mobile/features/documents/view/widgets/selection/confirm_delete_saved_view_dialog.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>( return BlocBuilder<ConnectivityCubit, ConnectivityState>(
builder: (context, connectivityState) { builder: (context, connectivityState) {
final hasInternetConnection = connectivityState.isConnected; final hasInternetConnection = connectivityState.isConnected;
return Column( return SizedBox(
mainAxisAlignment: MainAxisAlignment.end, height: height,
crossAxisAlignment: CrossAxisAlignment.start, child: Column(
children: [ mainAxisAlignment: MainAxisAlignment.start,
BlocBuilder<SavedViewCubit, SavedViewState>( crossAxisAlignment: CrossAxisAlignment.start,
builder: (context, state) { mainAxisSize: MainAxisSize.min,
if (!state.hasLoaded) { children: [
return _buildLoadingWidget(context); BlocBuilder<SavedViewCubit, SavedViewState>(
} builder: (context, state) {
if (state.value.isEmpty) { if (!state.hasLoaded) {
return Text(S.of(context).savedViewsEmptyStateText); return _buildLoadingWidget(context);
} }
return SizedBox( if (state.value.isEmpty) {
height: height, return Text(S.of(context).savedViewsEmptyStateText);
child: ListView.separated( }
itemCount: state.value.length, return SizedBox(
scrollDirection: Axis.horizontal, height: 38,
itemBuilder: (context, index) { child: ListView.separated(
final view = state.value.values.elementAt(index); itemCount: state.value.length,
return GestureDetector( scrollDirection: Axis.horizontal,
onLongPress: hasInternetConnection itemBuilder: (context, index) {
? () => _onDelete(context, view) final view = state.value.values.elementAt(index);
: null, return GestureDetector(
child: BlocBuilder<DocumentsCubit, DocumentsState>( onLongPress: hasInternetConnection
builder: (context, docState) { ? () => _onDelete(context, view)
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)
: null, : 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) { Widget _buildLoadingWidget(BuildContext context) {
final r = Random(123456789);
return SizedBox( return SizedBox(
height: height, height: 38,
width: double.infinity, width: MediaQuery.of(context).size.width,
child: Shimmer.fromColors( child: Shimmer.fromColors(
baseColor: Theme.of(context).brightness == Brightness.light baseColor: Theme.of(context).brightness == Brightness.light
? Colors.grey[300]! ? Colors.grey[300]!
@@ -123,14 +130,35 @@ class SavedViewSelectionWidget extends StatelessWidget {
highlightColor: Theme.of(context).brightness == Brightness.light highlightColor: Theme.of(context).brightness == Brightness.light
? Colors.grey[100]! ? Colors.grey[100]!
: Colors.grey[600]!, : Colors.grey[600]!,
child: ListView.separated( child: ListView(
scrollDirection: Axis.horizontal, scrollDirection: Axis.horizontal,
physics: const NeverScrollableScrollPhysics(), physics: const NeverScrollableScrollPhysics(),
itemCount: 10, children: [
itemBuilder: (context, index) => FilterChip( FilterChip(
label: SizedBox(width: r.nextInt((index * 20) + 50).toDouble()), label: const SizedBox(width: 32),
onSelected: null), onSelected: (_) {},
separatorBuilder: (context, index) => const SizedBox(width: 4.0), ),
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/home/view/widget/info_drawer.dart';
import 'package:paperless_mobile/features/scan/bloc/document_scanner_cubit.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/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/generated/l10n.dart';
import 'package:paperless_mobile/util.dart'; import 'package:paperless_mobile/util.dart';
import 'package:path/path.dart' as p; import 'package:path/path.dart' as p;
@@ -140,7 +141,7 @@ class _ScannerPageState extends State<ScannerPage>
final file = await _assembleFileBytes( final file = await _assembleFileBytes(
context.read<DocumentScannerCubit>().state, context.read<DocumentScannerCubit>().state,
); );
final uploaded = await Navigator.of(context).push( final taskId = await Navigator.of(context).push(
MaterialPageRoute( MaterialPageRoute(
builder: (_) => LabelRepositoriesProvider( builder: (_) => LabelRepositoriesProvider(
child: BlocProvider( child: BlocProvider(
@@ -165,8 +166,10 @@ class _ScannerPageState extends State<ScannerPage>
), ),
) ?? ) ??
false; 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<DocumentScannerCubit>().reset();
context.read<TaskStatusCubit>().listenToTaskChanges(taskId);
} }
} }

View File

@@ -7,20 +7,30 @@ class TaskStatusCubit extends Cubit<TaskStatusState> {
final PaperlessTasksApi _api; final PaperlessTasksApi _api;
TaskStatusCubit(this._api) : super(const TaskStatusState()); TaskStatusCubit(this._api) : super(const TaskStatusState());
void startListeningToTask(String taskId) { void listenToTaskChanges(String taskId) {
_api _api
.listenForTaskChanges(taskId) .listenForTaskChanges(taskId)
.forEach( .forEach(
(element) => TaskStatusState( (element) => emit(
isListening: true, TaskStatusState(
isAcknowledged: false, isListening: true,
task: element, task: element,
),
), ),
) )
.whenComplete(() => emit(state.copyWith(isListening: false))); .whenComplete(() => emit(state.copyWith(isListening: false)));
} }
void acknowledgeCurrentTask() { Future<void> acknowledgeCurrentTask() async {
emit(state.copyWith(isListening: false, isAcknowledged: true)); 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 { class TaskStatusState extends Equatable {
final Task? task; final Task? task;
final bool isListening; final bool isListening;
final bool isAcknowledged;
const TaskStatusState({ const TaskStatusState({
this.task, this.task,
this.isListening = false, this.isListening = false,
this.isAcknowledged = false,
}); });
bool get isActive => isListening && !isAcknowledged;
bool get isSuccess => task?.status == TaskStatus.success; bool get isSuccess => task?.status == TaskStatus.success;
bool get isAcknowledged => task?.acknowledged ?? false;
String? get taskId => task?.taskId; String? get taskId => task?.taskId;
@override @override
List<Object> get props => []; List<Object?> get props => [task, isListening];
TaskStatusState copyWith({ TaskStatusState copyWith({
Task? task, Task? task,
@@ -28,7 +26,6 @@ class TaskStatusState extends Equatable {
return TaskStatusState( return TaskStatusState(
task: task ?? this.task, task: task ?? this.task,
isListening: isListening ?? this.isListening, isListening: isListening ?? this.isListening,
isAcknowledged: isAcknowledged ?? this.isAcknowledged,
); );
} }
} }

View File

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

View File

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

View File

@@ -194,6 +194,8 @@
"@editLabelPageConfirmDeletionDialogTitle": {}, "@editLabelPageConfirmDeletionDialogTitle": {},
"editLabelPageDeletionDialogText": "This label contains references to other documents. By deleting this label, all references will be removed. Continue?", "editLabelPageDeletionDialogText": "This label contains references to other documents. By deleting this label, all references will be removed. Continue?",
"@editLabelPageDeletionDialogText": {}, "@editLabelPageDeletionDialogText": {},
"errorMessageAcknowledgeTasksError": "Could not acknowledge tasks.",
"@errorMessageAcknowledgeTasksError": {},
"errorMessageAuthenticationFailed": "Authentication failed, please try again.", "errorMessageAuthenticationFailed": "Authentication failed, please try again.",
"@errorMessageAuthenticationFailed": {}, "@errorMessageAuthenticationFailed": {},
"errorMessageAutocompleteQueryError": "An error ocurred while trying to autocomplete your query.", "errorMessageAutocompleteQueryError": "An error ocurred while trying to autocomplete your query.",
@@ -250,6 +252,8 @@
"@errorMessageStoragePathCreateFailed": {}, "@errorMessageStoragePathCreateFailed": {},
"errorMessageStoragePathLoadFailed": "Could not load storage paths.", "errorMessageStoragePathLoadFailed": "Could not load storage paths.",
"@errorMessageStoragePathLoadFailed": {}, "@errorMessageStoragePathLoadFailed": {},
"errorMessageSuggestionsQueryError": "Could not load suggestions.",
"@errorMessageSuggestionsQueryError": {},
"errorMessageTagCreateFailed": "Could not create tag, please try again.", "errorMessageTagCreateFailed": "Could not create tag, please try again.",
"@errorMessageTagCreateFailed": {}, "@errorMessageTagCreateFailed": {},
"errorMessageTagLoadFailed": "Could not load tags.", "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/bloc_changes_observer.dart';
import 'package:paperless_mobile/core/bloc/connectivity_cubit.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/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/interceptor/language_header.interceptor.dart';
import 'package:paperless_mobile/core/repository/impl/correspondent_repository_impl.dart'; import 'package:paperless_mobile/core/repository/impl/correspondent_repository_impl.dart';
import 'package:paperless_mobile/core/repository/impl/document_type_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/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/storage_path_repository_state.dart';
import 'package:paperless_mobile/core/repository/state/impl/tag_repository_state.dart'; import 'package:paperless_mobile/core/repository/state/impl/tag_repository_state.dart';
import 'package:paperless_mobile/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/connectivity_status_service.dart';
import 'package:paperless_mobile/core/service/dio_file_service.dart'; import 'package:paperless_mobile/core/service/dio_file_service.dart';
import 'package:paperless_mobile/core/service/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/features/tasks/cubit/task_status_cubit.dart';
import 'package:paperless_mobile/generated/l10n.dart'; import 'package:paperless_mobile/generated/l10n.dart';
import 'package:path_provider/path_provider.dart'; import 'package:path_provider/path_provider.dart';
import 'package:pretty_dio_logger/pretty_dio_logger.dart';
import 'package:provider/provider.dart'; import 'package:provider/provider.dart';
import 'package:receive_sharing_intent/receive_sharing_intent.dart'; import 'package:receive_sharing_intent/receive_sharing_intent.dart';
@@ -55,7 +53,6 @@ void main() async {
final widgetsBinding = WidgetsFlutterBinding.ensureInitialized(); final widgetsBinding = WidgetsFlutterBinding.ensureInitialized();
await findSystemLocale(); await findSystemLocale();
await LocalNotificationService.instance.initialize();
// Initialize External dependencies // Initialize External dependencies
final connectivity = Connectivity(); final connectivity = Connectivity();
@@ -78,27 +75,18 @@ void main() async {
final languageHeaderInterceptor = LanguageHeaderInterceptor( final languageHeaderInterceptor = LanguageHeaderInterceptor(
appSettingsCubit.state.preferredLocaleSubtag, appSettingsCubit.state.preferredLocaleSubtag,
); );
// Required for self signed client certificates // Manages security context, required for self signed client certificates
final dioWrapper = AuthenticationAwareDioManager([ final sessionManager = SessionManager([languageHeaderInterceptor]);
DioHttpErrorInterceptor(),
PrettyDioLogger(
compact: true,
responseBody: false,
responseHeader: false,
request: false,
requestBody: false,
requestHeader: false,
),
languageHeaderInterceptor,
]);
// Initialize Paperless APIs // Initialize Paperless APIs
final authApi = PaperlessAuthenticationApiImpl(dioWrapper.client); final authApi = PaperlessAuthenticationApiImpl(sessionManager.client);
final documentsApi = PaperlessDocumentsApiImpl(dioWrapper.client); final documentsApi = PaperlessDocumentsApiImpl(sessionManager.client);
final labelsApi = PaperlessLabelApiImpl(dioWrapper.client); final labelsApi = PaperlessLabelApiImpl(sessionManager.client);
final statsApi = PaperlessServerStatsApiImpl(dioWrapper.client); final statsApi = PaperlessServerStatsApiImpl(sessionManager.client);
final savedViewsApi = PaperlessSavedViewsApiImpl(dioWrapper.client); final savedViewsApi = PaperlessSavedViewsApiImpl(sessionManager.client);
final tasksApi = PaperlessTasksApiImpl(dioWrapper.client); final tasksApi = PaperlessTasksApiImpl(
sessionManager.client,
);
// Initialize Blocs/Cubits // Initialize Blocs/Cubits
final connectivityCubit = ConnectivityCubit(connectivityStatusService); final connectivityCubit = ConnectivityCubit(connectivityStatusService);
@@ -119,20 +107,23 @@ void main() async {
final authCubit = AuthenticationCubit( final authCubit = AuthenticationCubit(
localAuthService, localAuthService,
authApi, authApi,
dioWrapper, sessionManager,
); );
await authCubit await authCubit
.restoreSessionState(appSettingsCubit.state.isLocalAuthenticationEnabled); .restoreSessionState(appSettingsCubit.state.isLocalAuthenticationEnabled);
if (authCubit.state.isAuthenticated) { if (authCubit.state.isAuthenticated) {
final auth = authCubit.state.authentication!; final auth = authCubit.state.authentication!;
dioWrapper.updateSettings( sessionManager.updateSettings(
baseUrl: auth.serverUrl, baseUrl: auth.serverUrl,
authToken: auth.token, authToken: auth.token,
clientCertificate: auth.clientCertificate, clientCertificate: auth.clientCertificate,
); );
} }
final localNotificationService = LocalNotificationService();
await localNotificationService.initialize();
//Update language header in interceptor on language change. //Update language header in interceptor on language change.
appSettingsCubit.stream.listen((event) => languageHeaderInterceptor appSettingsCubit.stream.listen((event) => languageHeaderInterceptor
.preferredLocaleSubtag = event.preferredLocaleSubtag); .preferredLocaleSubtag = event.preferredLocaleSubtag);
@@ -149,7 +140,7 @@ void main() async {
create: (context) => cm.CacheManager( create: (context) => cm.CacheManager(
cm.Config( cm.Config(
'cacheKey', 'cacheKey',
fileService: DioFileService(dioWrapper.client), fileService: DioFileService(sessionManager.client),
), ),
), ),
), ),
@@ -157,6 +148,9 @@ void main() async {
Provider<ConnectivityStatusService>.value( Provider<ConnectivityStatusService>.value(
value: connectivityStatusService, value: connectivityStatusService,
), ),
Provider<LocalNotificationService>.value(
value: localNotificationService,
),
], ],
child: MultiRepositoryProvider( child: MultiRepositoryProvider(
providers: [ providers: [
@@ -221,6 +215,7 @@ class _PaperlessMobileEntrypointState extends State<PaperlessMobileEntrypoint> {
vertical: 16.0, vertical: 16.0,
), ),
), ),
materialTapTargetSize: MaterialTapTargetSize.shrinkWrap,
chipTheme: ChipThemeData( chipTheme: ChipThemeData(
backgroundColor: Colors.lightGreen[50], backgroundColor: Colors.lightGreen[50],
), ),
@@ -242,6 +237,7 @@ class _PaperlessMobileEntrypointState extends State<PaperlessMobileEntrypoint> {
vertical: 16.0, vertical: 16.0,
), ),
), ),
materialTapTargetSize: MaterialTapTargetSize.shrinkWrap,
chipTheme: ChipThemeData( chipTheme: ChipThemeData(
backgroundColor: Colors.green[900], backgroundColor: Colors.green[900],
), ),
@@ -252,7 +248,9 @@ class _PaperlessMobileEntrypointState extends State<PaperlessMobileEntrypoint> {
return MultiBlocProvider( return MultiBlocProvider(
providers: [ providers: [
BlocProvider( BlocProvider(
create: (context) => PaperlessServerInformationCubit(context.read()), create: (context) => PaperlessServerInformationCubit(
context.read<PaperlessServerStatsApi>(),
),
), ),
], ],
child: BlocBuilder<ApplicationSettingsCubit, ApplicationSettingsState>( child: BlocBuilder<ApplicationSettingsCubit, ApplicationSettingsState>(
@@ -260,14 +258,8 @@ class _PaperlessMobileEntrypointState extends State<PaperlessMobileEntrypoint> {
return MaterialApp( return MaterialApp(
debugShowCheckedModeBanner: true, debugShowCheckedModeBanner: true,
title: "Paperless Mobile", title: "Paperless Mobile",
theme: _lightTheme.copyWith( theme: _lightTheme,
listTileTheme: _lightTheme.listTileTheme darkTheme: _darkTheme,
.copyWith(tileColor: Colors.transparent),
),
darkTheme: _darkTheme.copyWith(
listTileTheme: _darkTheme.listTileTheme
.copyWith(tileColor: Colors.transparent),
),
themeMode: settings.preferredThemeMode, themeMode: settings.preferredThemeMode,
supportedLocales: S.delegate.supportedLocales, supportedLocales: S.delegate.supportedLocales,
locale: Locale.fromSubtags( locale: Locale.fromSubtags(

View File

@@ -131,6 +131,6 @@ class DocumentModel extends Equatable {
archiveSerialNumber, archiveSerialNumber,
originalFileName, originalFileName,
archivedFileName, 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 //Join values of all extended filter rules if exist
final FilterRule extendedFilterRule = filterRules if (filterRules.isNotEmpty) {
.where((r) => r.ruleType == extendedRule) final FilterRule extendedFilterRule = filterRules
.reduce((previousValue, element) => previousValue.copyWith( .where((r) => r.ruleType == extendedRule)
value: previousValue.value! + element.value!, .reduce((previousValue, element) => previousValue.copyWith(
)); value: previousValue.value! + element.value!,
filterRules ));
..removeWhere((element) => element.ruleType == extendedRule) filterRules
..add(extendedFilterRule); ..removeWhere((element) => element.ruleType == extendedRule)
..add(extendedFilterRule);
}
return filterRules; return filterRules;
} }

View File

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

View File

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

View File

@@ -1,3 +1,5 @@
import 'package:paperless_api/src/request_utils.dart';
class PaperlessServerInformationModel { class PaperlessServerInformationModel {
static const String versionHeader = 'x-version'; static const String versionHeader = 'x-version';
static const String apiVersionHeader = 'x-api-version'; static const String apiVersionHeader = 'x-api-version';
@@ -13,4 +15,9 @@ class PaperlessServerInformationModel {
this.version = 'unknown', this.version = 'unknown',
this.apiVersion = 1, 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 String? result;
final bool acknowledged; final bool acknowledged;
@JsonKey(fromJson: tryParseNullable) @JsonKey(fromJson: tryParseNullable)
final int? relatedDocumentId; final int? relatedDocument;
const Task({ const Task({
required this.id, required this.id,
@@ -28,7 +28,7 @@ class Task extends Equatable {
this.type, this.type,
this.status, this.status,
this.acknowledged = false, this.acknowledged = false,
this.relatedDocumentId, this.relatedDocument,
this.result, this.result,
}); });
@@ -47,6 +47,32 @@ class Task extends Equatable {
status, status,
result, result,
acknowledged, 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?, type: json['type'] as String?,
status: $enumDecodeNullable(_$TaskStatusEnumMap, json['status']), status: $enumDecodeNullable(_$TaskStatusEnumMap, json['status']),
acknowledged: json['acknowledged'] as bool? ?? false, acknowledged: json['acknowledged'] as bool? ?? false,
relatedDocumentId: relatedDocument: tryParseNullable(json['related_document'] as String?),
tryParseNullable(json['related_document_id'] as String?),
result: json['result'] as String?, result: json['result'] as String?,
); );
@@ -32,7 +31,7 @@ Map<String, dynamic> _$TaskToJson(Task instance) => <String, dynamic>{
'status': _$TaskStatusEnumMap[instance.status], 'status': _$TaskStatusEnumMap[instance.status],
'result': instance.result, 'result': instance.result,
'acknowledged': instance.acknowledged, 'acknowledged': instance.acknowledged,
'related_document_id': instance.relatedDocumentId, 'related_document': instance.relatedDocument,
}; };
const _$TaskStatusEnumMap = { const _$TaskStatusEnumMap = {

View File

@@ -1,11 +1,6 @@
import 'dart:typed_data'; import 'dart:typed_data';
import 'package:paperless_api/src/models/bulk_edit_model.dart'; import 'package:paperless_api/src/models/models.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';
abstract class PaperlessDocumentsApi { abstract class PaperlessDocumentsApi {
/// Uploads a document using a form data request and from server version 1.11.3 /// 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<DocumentModel> update(DocumentModel doc);
Future<int> findNextAsn(); 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<List<SimilarDocumentModel>> findSimilar(int docId);
Future<int> delete(DocumentModel doc); Future<int> delete(DocumentModel doc);
Future<DocumentMetaData> getMetaData(DocumentModel document); Future<DocumentMetaData> getMetaData(DocumentModel document);
Future<Iterable<int>> bulkAction(BulkAction action); Future<Iterable<int>> bulkAction(BulkAction action);
Future<Uint8List> getPreview(int docId); Future<Uint8List> getPreview(int docId);
String getThumbnailUrl(int docId); String getThumbnailUrl(int docId);
Future<DocumentModel> waitForConsumptionFinished(
String filename,
String title,
);
Future<Uint8List> download(DocumentModel document); Future<Uint8List> download(DocumentModel document);
Future<FieldSuggestions> findSuggestions(DocumentModel document);
Future<List<String>> autocomplete(String query, [int limit = 10]); Future<List<String>> autocomplete(String query, [int limit = 10]);
} }

View File

@@ -1,13 +1,9 @@
import 'dart:convert'; import 'dart:convert';
import 'dart:io';
import 'package:dio/dio.dart'; import 'package:dio/dio.dart';
import 'package:flutter/foundation.dart'; import 'package:flutter/foundation.dart';
import 'package:paperless_api/paperless_api.dart'; import 'package:paperless_api/paperless_api.dart';
import 'package:paperless_api/src/constants.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 { class PaperlessDocumentsApiImpl implements PaperlessDocumentsApi {
final Dio client; final Dio client;
@@ -82,8 +78,11 @@ class PaperlessDocumentsApiImpl implements PaperlessDocumentsApi {
} }
@override @override
Future<PagedSearchResult<DocumentModel>> find(DocumentFilter filter) async { Future<PagedSearchResult<DocumentModel>> findAll(
final filterParams = filter.toQueryParameters(); DocumentFilter filter,
) async {
final filterParams = filter.toQueryParameters()
..addAll({'truncate_content': "true"});
try { try {
final response = await client.get( final response = await client.get(
"/api/documents/", "/api/documents/",
@@ -156,7 +155,7 @@ class PaperlessDocumentsApiImpl implements PaperlessDocumentsApi {
pageSize: 1, pageSize: 1,
); );
try { try {
final result = await find(asnQueryFilter); final result = await findAll(asnQueryFilter);
return result.results return result.results
.map((e) => e.archiveSerialNumber) .map((e) => e.archiveSerialNumber)
.firstWhere((asn) => asn != null, orElse: () => 0)! + .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 @override
Future<Uint8List> download(DocumentModel document) async { Future<Uint8List> download(DocumentModel document) async {
try { try {
@@ -273,4 +252,32 @@ class PaperlessDocumentsApiImpl implements PaperlessDocumentsApi {
throw err.error; 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<Task?> find({int? id, String? taskId});
Future<Iterable<Task>> findAll([Iterable<int>? ids]); Future<Iterable<Task>> findAll([Iterable<int>? ids]);
Stream<Task> listenForTaskChanges(String taskId); 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 'dart:developer';
import 'package:paperless_api/src/models/task/task.dart';
import 'package:paperless_api/src/models/task/task_status.dart';
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 { class PaperlessTasksApiImpl implements PaperlessTasksApi {
final Dio client; final Dio _client;
const PaperlessTasksApiImpl(this.client); PaperlessTasksApiImpl(this._client);
@override @override
Future<Task?> find({int? id, String? taskId}) async { Future<Task?> find({int? id, String? taskId}) async {
assert(id != null || taskId != null); assert((id != null) != (taskId != null));
String url = "/api/tasks/"; if (id != null) {
if (taskId != null) { return _findById(id);
url += "?task_id=$taskId"; } else if (taskId != null) {
} else { return _findByTaskId(taskId);
url += "$id/";
} }
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) { if (response.statusCode == 200) {
return Task.fromJson(response.data); return Task.fromJson(response.data);
} }
return null; 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 @override
Future<Iterable<Task>> findAll([Iterable<int>? ids]) async { 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) { if (response.statusCode == 200) {
return (response.data as List).map((e) => Task.fromJson(e)); return (response.data as List).map((e) => Task.fromJson(e));
} }
@@ -37,17 +51,39 @@ class PaperlessTasksApiImpl implements PaperlessTasksApi {
@override @override
Stream<Task> listenForTaskChanges(String taskId) async* { Stream<Task> listenForTaskChanges(String taskId) async* {
bool isSuccess = false; bool isCompleted = false;
while (!isSuccess) { while (!isCompleted) {
final task = await find(taskId: taskId); final task = await find(taskId: taskId);
if (task == null) { if (task == null) {
throw Exception("Task with taskId $taskId does not exist."); throw Exception("Task with taskId $taskId does not exist.");
} }
log("Found new task: ${task.taskId}, ${task.id}, ${task.status}");
yield task; yield task;
if (task.status == TaskStatus.success) { if (task.status == TaskStatus.success ||
isSuccess = true; task.status == TaskStatus.failure) {
isCompleted = true;
} }
await Future.delayed(const Duration(seconds: 1)); 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" url: "https://pub.dev"
source: hosted source: hosted
version: "2.0.7" 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: flutter_svg:
dependency: "direct main" dependency: "direct main"
description: description:
@@ -696,14 +704,6 @@ packages:
description: flutter description: flutter
source: sdk source: sdk
version: "0.0.0" 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: glob:
dependency: transitive dependency: transitive
description: description:
@@ -784,30 +784,6 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "3.3.0" 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: integration_test:
dependency: "direct dev" dependency: "direct dev"
description: flutter description: flutter
@@ -1308,14 +1284,6 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "3.0.1" 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: receive_sharing_intent:
dependency: "direct main" dependency: "direct main"
description: description:
@@ -1457,14 +1425,6 @@ packages:
description: flutter description: flutter
source: sdk source: sdk
version: "0.0.99" 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: source_gen:
dependency: transitive dependency: transitive
description: description:

View File

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