Merge branch 'feature/share_via_app'

This commit is contained in:
Anton Stubenbord
2022-11-13 14:42:11 +01:00
56 changed files with 1014 additions and 788 deletions
+6 -1
View File
@@ -28,8 +28,13 @@
An (almost) fully fledged mobile paperless client.
<br />
<br />
<p>
<a href="https://play.google.com/store/apps/details?id=de.astubenbord.paperless_mobile">
<img src="resources/get_it_on_google_play_en.svg" width="140px">
</a>
</p>
<!--<a href="https://github.com/astubenbord/paperless-mobile">View Demo</a>
·-->
·-->
<a href="https://github.com/astubenbord/paperless-mobile/issues">Report Bug</a>
·
<a href="https://github.com/astubenbord/paperless-mobile/issues">Request Feature</a>
+24 -2
View File
@@ -1,6 +1,15 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android" package="com.example.paperless_mobile">
<application android:label="Paperless Mobile" android:name="${applicationName}" android:icon="@mipmap/launcher_icon">
<activity android:name=".MainActivity" android:exported="true" android:launchMode="singleTop" android:theme="@style/LaunchTheme" android:configChanges="orientation|keyboardHidden|keyboard|screenSize|smallestScreenSize|locale|layoutDirection|fontScale|screenLayout|density|uiMode" android:hardwareAccelerated="true" android:windowSoftInputMode="adjustResize">
<application android:label="Paperless Mobile"
android:name="${applicationName}"
android:icon="@mipmap/launcher_icon">
<activity
android:name=".MainActivity"
android:exported="true"
android:launchMode="singleTask"
android:theme="@style/LaunchTheme"
android:configChanges="orientation|keyboardHidden|keyboard|screenSize|smallestScreenSize|locale|layoutDirection|fontScale|screenLayout|density|uiMode"
android:hardwareAccelerated="true"
android:windowSoftInputMode="adjustResize">
<!-- Specifies an Android theme to apply to this Activity as soon as
the Android process has started. This theme is visible to the user
while the Flutter UI initializes. After that, this theme continues
@@ -10,6 +19,19 @@
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
<!-- Enables sharing image files "into" this app -->
<intent-filter>
<action android:name="android.intent.action.SEND" />
<category android:name="android.intent.category.DEFAULT" />
<data android:mimeType="image/*" />
</intent-filter>
<!-- Enables sharing pdf files "into" this app -->
<intent-filter>
<action android:name="android.intent.action.SEND" />
<category android:name="android.intent.category.DEFAULT" />
<data android:mimeType="application/pdf" />
</intent-filter>
</activity>
<!-- Don't delete the meta-data below.
This is used by the Flutter tool to generate GeneratedPluginRegistrant.java -->
-46
View File
@@ -1,46 +0,0 @@
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:injectable/injectable.dart';
import 'package:paperless_mobile/core/model/error_message.dart';
///
/// Class for handling generic errors which usually only require to inform the user via a Snackbar
/// or similar that an error has occurred.
///
@singleton
class GlobalErrorCubit extends Cubit<GlobalErrorState> {
static const _waitBeforeNextErrorDuration = Duration(seconds: 5);
GlobalErrorCubit() : super(GlobalErrorState.initial);
///
/// Adds a new error to this bloc. If the new error is equal to the current error, the new error
/// will not be published unless the previous error occured over 5 seconds ago.
///
void add(ErrorMessage error) {
final now = DateTime.now();
if (error != state.error || (error == state.error && _canEmitNewError())) {
emit(GlobalErrorState(error: error, errorTimestamp: now));
}
}
bool _canEmitNewError() {
if (state.errorTimestamp != null) {
return DateTime.now().difference(state.errorTimestamp!).inSeconds >= 5;
}
return true;
}
void reset() {
emit(GlobalErrorState.initial);
}
}
class GlobalErrorState {
static const GlobalErrorState initial = GlobalErrorState();
final ErrorMessage? error;
final DateTime? errorTimestamp;
const GlobalErrorState({this.error, this.errorTimestamp});
bool get hasError => error != null;
}
+18 -51
View File
@@ -1,75 +1,42 @@
import 'package:flutter/foundation.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:paperless_mobile/core/bloc/global_error_cubit.dart';
import 'package:paperless_mobile/core/model/error_message.dart';
import 'package:paperless_mobile/features/labels/model/label.model.dart';
import 'package:paperless_mobile/features/labels/repository/label_repository.dart';
abstract class LabelCubit<T extends Label> extends Cubit<Map<int, T>> {
final LabelRepository labelRepository;
final GlobalErrorCubit errorCubit;
LabelCubit(this.labelRepository, this.errorCubit) : super({});
LabelCubit(this.labelRepository) : super({});
@protected
void loadFrom(Iterable<T> items) =>
emit(Map.fromIterable(items, key: (e) => (e as T).id!));
Future<T> add(
T item, {
bool propagateEventOnError = true,
}) async {
Future<T> add(T item) async {
assert(item.id == null);
try {
final addedItem = await save(item);
final newState = {...state};
newState.putIfAbsent(addedItem.id!, () => addedItem);
emit(newState);
return addedItem;
} on ErrorMessage catch (error) {
if (propagateEventOnError) {
errorCubit.add(error);
}
return Future.error(error);
}
final addedItem = await save(item);
final newState = {...state};
newState.putIfAbsent(addedItem.id!, () => addedItem);
emit(newState);
return addedItem;
}
Future<T> replace(
T item, {
bool propagateEventOnError = true,
}) async {
Future<T> replace(T item) async {
assert(item.id != null);
try {
final updatedItem = await update(item);
final newState = {...state};
newState[item.id!] = updatedItem;
emit(newState);
return updatedItem;
} on ErrorMessage catch (error) {
if (propagateEventOnError) {
errorCubit.add(error);
}
return Future.error(error);
}
final updatedItem = await update(item);
final newState = {...state};
newState[item.id!] = updatedItem;
emit(newState);
return updatedItem;
}
Future<void> remove(
T item, {
bool propagateEventOnError = true,
}) async {
Future<void> remove(T item) async {
assert(item.id != null);
if (state.containsKey(item.id)) {
try {
final deletedId = await delete(item);
final newState = {...state};
newState.remove(deletedId);
emit(newState);
} on ErrorMessage catch (error) {
if (propagateEventOnError) {
errorCubit.add(error);
}
return Future.error(error);
}
final deletedId = await delete(item);
final newState = {...state};
newState.remove(deletedId);
emit(newState);
}
}
+1
View File
@@ -0,0 +1 @@
const supportedFileExtensions = ['pdf', 'png', 'tiff', 'gif', 'jpg', 'jpeg'];
@@ -66,5 +66,7 @@ String translateError(BuildContext context, ErrorCode code) {
return S.of(context).errorMessageRequestTimedOut;
case ErrorCode.unsupportedFileFormat:
return S.of(context).errorMessageUnsupportedFileFormat;
case ErrorCode.missingClientCertificate:
return S.of(context).errorMessageMissingClientCertificate;
}
}
+40 -12
View File
@@ -35,8 +35,12 @@ class TimeoutClient implements BaseClient {
}
@override
Future<Response> delete(Uri url,
{Map<String, String>? headers, Object? body, Encoding? encoding}) async {
Future<Response> delete(
Uri url, {
Map<String, String>? headers,
Object? body,
Encoding? encoding,
}) async {
await _handleOfflineState();
return _handle400Error(
await getIt<BaseClient>()
@@ -50,7 +54,10 @@ class TimeoutClient implements BaseClient {
}
@override
Future<Response> get(Uri url, {Map<String, String>? headers}) async {
Future<Response> get(
Uri url, {
Map<String, String>? headers,
}) async {
await _handleOfflineState();
return _handle400Error(
await getIt<BaseClient>().get(url, headers: headers).timeout(
@@ -62,7 +69,10 @@ class TimeoutClient implements BaseClient {
}
@override
Future<Response> head(Uri url, {Map<String, String>? headers}) async {
Future<Response> head(
Uri url, {
Map<String, String>? headers,
}) async {
await _handleOfflineState();
return _handle400Error(
await getIt<BaseClient>().head(url, headers: headers).timeout(
@@ -74,8 +84,12 @@ class TimeoutClient implements BaseClient {
}
@override
Future<Response> patch(Uri url,
{Map<String, String>? headers, Object? body, Encoding? encoding}) async {
Future<Response> patch(
Uri url, {
Map<String, String>? headers,
Object? body,
Encoding? encoding,
}) async {
await _handleOfflineState();
return _handle400Error(
await getIt<BaseClient>()
@@ -89,8 +103,12 @@ class TimeoutClient implements BaseClient {
}
@override
Future<Response> post(Uri url,
{Map<String, String>? headers, Object? body, Encoding? encoding}) async {
Future<Response> post(
Uri url, {
Map<String, String>? headers,
Object? body,
Encoding? encoding,
}) async {
await _handleOfflineState();
return _handle400Error(
await getIt<BaseClient>()
@@ -104,8 +122,12 @@ class TimeoutClient implements BaseClient {
}
@override
Future<Response> put(Uri url,
{Map<String, String>? headers, Object? body, Encoding? encoding}) async {
Future<Response> put(
Uri url, {
Map<String, String>? headers,
Object? body,
Encoding? encoding,
}) async {
await _handleOfflineState();
return _handle400Error(
await getIt<BaseClient>()
@@ -119,7 +141,10 @@ class TimeoutClient implements BaseClient {
}
@override
Future<String> read(Uri url, {Map<String, String>? headers}) async {
Future<String> read(
Uri url, {
Map<String, String>? headers,
}) async {
await _handleOfflineState();
return getIt<BaseClient>().read(url, headers: headers).timeout(
requestTimeout,
@@ -129,7 +154,10 @@ class TimeoutClient implements BaseClient {
}
@override
Future<Uint8List> readBytes(Uri url, {Map<String, String>? headers}) async {
Future<Uint8List> readBytes(
Uri url, {
Map<String, String>? headers,
}) async {
await _handleOfflineState();
return getIt<BaseClient>().readBytes(url, headers: headers).timeout(
requestTimeout,
+5 -2
View File
@@ -1,9 +1,11 @@
class ErrorMessage implements Exception {
final ErrorCode code;
final String? details;
final StackTrace? stackTrace;
final int? httpStatusCode;
const ErrorMessage(this.code, {this.stackTrace, this.httpStatusCode});
const ErrorMessage(this.code,
{this.details, this.stackTrace, this.httpStatusCode});
factory ErrorMessage.unknown() {
return const ErrorMessage(ErrorCode.unknown);
@@ -46,5 +48,6 @@ enum ErrorCode {
createSavedViewError,
deleteSavedViewError,
requestTimedOut,
unsupportedFileFormat;
unsupportedFileFormat,
missingClientCertificate;
}
+95
View File
@@ -0,0 +1,95 @@
import 'dart:io';
import 'package:flutter/foundation.dart';
import 'package:paperless_mobile/core/model/error_message.dart';
import 'package:path_provider/path_provider.dart';
import 'package:uuid/uuid.dart';
class FileService {
static Future<File> saveToFile(
Uint8List bytes,
String filename,
) async {
final dir = await documentsDirectory;
if (dir == null) {
throw ErrorMessage.unknown(); //TODO: better handling
}
File file = File("${dir.path}/$filename");
return file..writeAsBytes(bytes);
}
static Future<Directory?> getDirectory(PaperlessDirectoryType type) {
switch (type) {
case PaperlessDirectoryType.documents:
return documentsDirectory;
case PaperlessDirectoryType.temporary:
return temporaryDirectory;
case PaperlessDirectoryType.scans:
return scanDirectory;
case PaperlessDirectoryType.download:
return downloadsDirectory;
}
}
static Future<File> allocateTemporaryFile(
PaperlessDirectoryType type, {
required String extension,
String? fileName,
}) async {
final dir = await getDirectory(type);
final _fileName = (fileName ?? const Uuid().v1()) + '.$extension';
return File('${dir?.path}/$_fileName');
}
static Future<Directory> get temporaryDirectory => getTemporaryDirectory();
static Future<Directory?> get documentsDirectory async {
if (Platform.isAndroid) {
return (await getExternalStorageDirectories(
type: StorageDirectory.documents,
))!
.first;
} else if (Platform.isIOS) {
return getApplicationDocumentsDirectory();
} else {
throw UnsupportedError("Platform not supported.");
}
}
static Future<Directory?> get downloadsDirectory async {
if (Platform.isAndroid) {
return (await getExternalStorageDirectories(
type: StorageDirectory.downloads))!
.first;
} else if (Platform.isIOS) {
return getApplicationDocumentsDirectory();
} else {
throw UnsupportedError("Platform not supported.");
}
}
static Future<Directory?> get scanDirectory async {
if (Platform.isAndroid) {
return (await getExternalStorageDirectories(type: StorageDirectory.dcim))!
.first;
} else if (Platform.isIOS) {
return getApplicationDocumentsDirectory();
} else {
throw UnsupportedError("Platform not supported.");
}
}
static Future<void> clearUserData() async {
final scanDir = await scanDirectory;
final tempDir = await temporaryDirectory;
scanDir?.delete(recursive: true);
tempDir.delete(recursive: true);
}
}
enum PaperlessDirectoryType {
documents,
temporary,
scans,
download;
}
+6 -2
View File
@@ -1,6 +1,7 @@
import 'dart:convert';
import 'dart:io';
import 'package:flutter/foundation.dart';
import 'package:paperless_mobile/core/bloc/document_status_cubit.dart';
import 'package:paperless_mobile/core/model/document_processing_status.dart';
import 'package:paperless_mobile/di_initializer.dart';
@@ -89,8 +90,11 @@ class LongPollingStatusService implements StatusService {
Uri.parse(
'$httpUrl/api/documents/?query=$documentFileName added:${formatDate(today)}'),
);
final data = PagedSearchResult.fromJson(
jsonDecode(response.body), DocumentModel.fromJson);
final data = await compute(
PagedSearchResult.fromJson,
PagedSearchResultJsonSerializer(
jsonDecode(response.body), DocumentModel.fromJson),
);
if (data.count > 0) {
consumptionFinished = true;
final docId = data.results[0].id;
+20 -21
View File
@@ -1,13 +1,9 @@
import 'dart:convert';
import 'dart:io';
import 'dart:typed_data';
import 'package:paperless_mobile/core/logic/timeout_client.dart';
import 'package:flutter/foundation.dart';
import 'package:paperless_mobile/core/model/error_message.dart';
import 'package:paperless_mobile/core/type/types.dart';
import 'package:paperless_mobile/di_initializer.dart';
import 'package:http/http.dart';
import 'package:path_provider/path_provider.dart';
const requestTimeout = Duration(seconds: 5);
@@ -23,7 +19,10 @@ Future<T> getSingleResult<T>(
headers: {'accept': 'application/json; version=$minRequiredApiVersion'},
);
if (response.statusCode == 200) {
return fromJson(jsonDecode(utf8.decode(response.bodyBytes)) as JSON);
return compute(
fromJson,
jsonDecode(utf8.decode(response.bodyBytes)) as JSON,
);
}
return Future.error(errorCode);
}
@@ -45,25 +44,25 @@ Future<List<T>> getCollection<T>(
if (body['count'] == 0) {
return <T>[];
} else {
return body['results']
.cast<JSON>()
.map<T>((result) => fromJson(result))
.toList();
return compute(
_collectionFromJson,
_CollectionFromJsonSerializationParams(
fromJson, (body['results'] as List).cast<JSON>()),
);
}
}
}
return Future.error(errorCode);
}
class FileUtils {
static Future<File> saveToFile(
Uint8List bytes,
String filename, {
StorageDirectory directoryType = StorageDirectory.documents,
}) async {
final dir = (await getExternalStorageDirectories(type: directoryType));
File file = File("$dir/$filename");
file.writeAsBytesSync(bytes);
return file;
}
List<T> _collectionFromJson<T>(
_CollectionFromJsonSerializationParams<T> params) {
return params.list.map<T>((result) => params.fromJson(result)).toList();
}
class _CollectionFromJsonSerializationParams<T> {
final T Function(JSON) fromJson;
final List<JSON> list;
_CollectionFromJsonSerializationParams(this.fromJson, this.list);
}
+53 -166
View File
@@ -1,7 +1,6 @@
import 'dart:typed_data';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:paperless_mobile/core/bloc/global_error_cubit.dart';
import 'package:paperless_mobile/core/model/error_message.dart';
import 'package:paperless_mobile/features/documents/bloc/documents_state.dart';
import 'package:paperless_mobile/features/documents/model/document.model.dart';
@@ -13,10 +12,8 @@ import 'package:injectable/injectable.dart';
@singleton
class DocumentsCubit extends Cubit<DocumentsState> {
final DocumentRepository documentRepository;
final GlobalErrorCubit errorCubit;
DocumentsCubit(this.documentRepository, this.errorCubit)
: super(DocumentsState.initial);
DocumentsCubit(this.documentRepository) : super(DocumentsState.initial);
Future<void> addDocument(
Uint8List bytes,
@@ -27,209 +24,99 @@ class DocumentsCubit extends Cubit<DocumentsState> {
int? correspondent,
List<int>? tags,
DateTime? createdAt,
bool propagateEventOnError = true,
}) async {
try {
await documentRepository.create(
bytes,
fileName,
title: title,
documentType: documentType,
correspondent: correspondent,
tags: tags,
createdAt: createdAt,
);
} on ErrorMessage catch (error) {
if (propagateEventOnError) {
errorCubit.add(error);
} else {
rethrow;
}
}
await documentRepository.create(
bytes,
fileName,
title: title,
documentType: documentType,
correspondent: correspondent,
tags: tags,
createdAt: createdAt,
);
// documentRepository
// .waitForConsumptionFinished(fileName, title)
// .then((value) => onConsumptionFinished(value));
}
Future<void> removeDocument(
DocumentModel document, {
bool propagateEventOnError = true,
}) async {
try {
await documentRepository.delete(document);
return await reloadDocuments();
} on ErrorMessage catch (error) {
if (propagateEventOnError) {
errorCubit.add(error);
} else {
rethrow;
}
}
Future<void> removeDocument(DocumentModel document) async {
await documentRepository.delete(document);
return await reloadDocuments();
}
Future<void> bulkRemoveDocuments(List<DocumentModel> documents,
{bool propagateEventOnError = true}) async {
try {
await documentRepository.bulkDelete(documents);
return await reloadDocuments();
} on ErrorMessage catch (error) {
if (propagateEventOnError) {
errorCubit.add(error);
} else {
rethrow;
}
}
Future<void> bulkRemoveDocuments(List<DocumentModel> documents) async {
await documentRepository.bulkDelete(documents);
return await reloadDocuments();
}
Future<void> updateDocument(
DocumentModel document, {
bool propagateEventOnError = true,
}) async {
try {
await documentRepository.update(document);
await reloadDocuments();
} on ErrorMessage catch (error) {
if (propagateEventOnError) {
errorCubit.add(error);
} else {
rethrow;
}
}
Future<void> updateDocument(DocumentModel document) async {
await documentRepository.update(document);
await reloadDocuments();
}
Future<void> loadDocuments({
bool propagateEventOnError = true,
}) async {
try {
final result = await documentRepository.find(state.filter);
emit(DocumentsState(
isLoaded: true,
value: [...state.value, result],
filter: state.filter,
));
} on ErrorMessage catch (error) {
if (propagateEventOnError) {
errorCubit.add(error);
} else {
rethrow;
}
}
Future<void> loadDocuments() async {
final result = await documentRepository.find(state.filter);
emit(DocumentsState(
isLoaded: true,
value: [...state.value, result],
filter: state.filter,
));
}
Future<void> reloadDocuments({
bool propagateEventOnError = true,
}) async {
Future<void> reloadDocuments() async {
if (state.currentPageNumber >= 5) {
return _bulkReloadDocuments();
}
var newPages = <PagedSearchResult>[];
try {
for (final page in state.value) {
final result = await documentRepository
.find(state.filter.copyWith(page: page.pageKey));
newPages.add(result);
}
emit(DocumentsState(
isLoaded: true, value: newPages, filter: state.filter));
} on ErrorMessage catch (error) {
if (propagateEventOnError) {
errorCubit.add(error);
} else {
rethrow;
}
for (final page in state.value) {
final result = await documentRepository
.find(state.filter.copyWith(page: page.pageKey));
newPages.add(result);
}
emit(DocumentsState(isLoaded: true, value: newPages, filter: state.filter));
}
Future<void> _bulkReloadDocuments({
bool propagateEventOnError = true,
}) async {
try {
final result = await documentRepository.find(
state.filter.copyWith(page: 1, pageSize: state.documents.length));
emit(DocumentsState(
isLoaded: true, value: [result], filter: state.filter));
} on ErrorMessage catch (error) {
if (propagateEventOnError) {
errorCubit.add(error);
} else {
rethrow;
}
}
Future<void> _bulkReloadDocuments() async {
final result = await documentRepository
.find(state.filter.copyWith(page: 1, pageSize: state.documents.length));
emit(DocumentsState(isLoaded: true, value: [result], filter: state.filter));
}
Future<void> loadMore({
bool propagateEventOnError = true,
}) async {
Future<void> loadMore() async {
if (state.isLastPageLoaded) {
return;
}
final newFilter = state.filter.copyWith(page: state.filter.page + 1);
try {
final result = await documentRepository.find(newFilter);
emit(DocumentsState(
isLoaded: true, value: [...state.value, result], filter: newFilter));
} on ErrorMessage catch (error) {
if (propagateEventOnError) {
errorCubit.add(error);
} else {
rethrow;
}
}
final result = await documentRepository.find(newFilter);
emit(DocumentsState(
isLoaded: true, value: [...state.value, result], filter: newFilter));
}
Future<void> assignAsn(
DocumentModel document, {
bool propagateEventOnError = true,
}) async {
try {
if (document.archiveSerialNumber == null) {
final int asn = await documentRepository.findNextAsn();
updateDocument(document.copyWith(archiveSerialNumber: asn));
}
} on ErrorMessage catch (error) {
if (propagateEventOnError) {
errorCubit.add(error);
} else {
rethrow;
}
Future<void> assignAsn(DocumentModel document) async {
if (document.archiveSerialNumber == null) {
final int asn = await documentRepository.findNextAsn();
updateDocument(document.copyWith(archiveSerialNumber: asn));
}
}
///
/// Update filter state and automatically reload documents. Always resets page to 1.
/// Use [DocumentsCubit.loadMore] to load more data.
Future<void> updateFilter(
{final DocumentFilter filter = DocumentFilter.initial,
bool propagateEventOnError = true}) async {
try {
final result = await documentRepository.find(filter.copyWith(page: 1));
emit(DocumentsState(filter: filter, value: [result], isLoaded: true));
} on ErrorMessage catch (error) {
if (propagateEventOnError) {
errorCubit.add(error);
} else {
rethrow;
}
}
Future<void> updateFilter({
final DocumentFilter filter = DocumentFilter.initial,
}) async {
final result = await documentRepository.find(filter.copyWith(page: 1));
emit(DocumentsState(filter: filter, value: [result], isLoaded: true));
}
///
/// Convenience method which allows to directly use [DocumentFilter.copyWith] on the current filter.
///
Future<void> updateCurrentFilter(
final DocumentFilter Function(DocumentFilter) transformFn, {
bool propagateEventOnError = true,
}) async {
try {
return updateFilter(filter: transformFn(state.filter));
} on ErrorMessage catch (error) {
if (propagateEventOnError) {
return errorCubit.add(error);
} else {
rethrow;
}
}
}
final DocumentFilter Function(DocumentFilter) transformFn,
) async =>
updateFilter(filter: transformFn(state.filter));
void toggleDocumentSelection(DocumentModel model) {
if (state.selection.contains(model)) {
@@ -1,5 +1,4 @@
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:paperless_mobile/core/bloc/global_error_cubit.dart';
import 'package:paperless_mobile/core/model/error_message.dart';
import 'package:paperless_mobile/di_initializer.dart';
import 'package:paperless_mobile/features/documents/bloc/saved_view_state.dart';
@@ -9,79 +8,42 @@ import 'package:injectable/injectable.dart';
@singleton
class SavedViewCubit extends Cubit<SavedViewState> {
final GlobalErrorCubit errorCubit;
SavedViewCubit(this.errorCubit) : super(SavedViewState(value: {}));
SavedViewCubit() : super(SavedViewState(value: {}));
void selectView(SavedView? view, {bool propagateEventOnError = true}) {
try {
emit(SavedViewState(value: state.value, selectedSavedViewId: view?.id));
} on ErrorMessage catch (error) {
if (propagateEventOnError) {
errorCubit.add(error);
}
rethrow;
}
void selectView(SavedView? view) {
emit(SavedViewState(value: state.value, selectedSavedViewId: view?.id));
}
Future<SavedView> add(
SavedView view, {
bool propagateEventOnError = true,
}) async {
try {
final savedView = await getIt<SavedViewsRepository>().save(view);
emit(
SavedViewState(
value: {...state.value, savedView.id!: savedView},
selectedSavedViewId: state.selectedSavedViewId,
),
);
return savedView;
} on ErrorMessage catch (error) {
if (propagateEventOnError) {
errorCubit.add(error);
}
rethrow;
}
Future<SavedView> add(SavedView view) async {
final savedView = await getIt<SavedViewsRepository>().save(view);
emit(
SavedViewState(
value: {...state.value, savedView.id!: savedView},
selectedSavedViewId: state.selectedSavedViewId,
),
);
return savedView;
}
Future<int> remove(
SavedView view, {
bool propagateEventOnError = true,
}) async {
try {
final id = await getIt<SavedViewsRepository>().delete(view);
final newValue = {...state.value};
newValue.removeWhere((key, value) => key == id);
emit(
SavedViewState(
value: newValue,
selectedSavedViewId: view.id == state.selectedSavedViewId
? null
: state.selectedSavedViewId,
),
);
return id;
} on ErrorMessage catch (error) {
if (propagateEventOnError) {
errorCubit.add(error);
}
rethrow;
}
Future<int> remove(SavedView view) async {
final id = await getIt<SavedViewsRepository>().delete(view);
final newValue = {...state.value};
newValue.removeWhere((key, value) => key == id);
emit(
SavedViewState(
value: newValue,
selectedSavedViewId: view.id == state.selectedSavedViewId
? null
: state.selectedSavedViewId,
),
);
return id;
}
Future<void> initialize({
bool propagateEventOnError = true,
}) async {
try {
final views = await getIt<SavedViewsRepository>().getAll();
final values = {for (var element in views) element.id!: element};
emit(SavedViewState(value: values));
} on ErrorMessage catch (error) {
if (propagateEventOnError) {
errorCubit.add(error);
}
rethrow;
}
Future<void> initialize() async {
final views = await getIt<SavedViewsRepository>().getAll();
final values = {for (var element in views) element.id!: element};
emit(SavedViewState(value: values));
}
void resetSelection() {
@@ -4,6 +4,13 @@ import 'package:paperless_mobile/features/documents/model/document.model.dart';
const pageRegex = r".*page=(\d+).*";
class PagedSearchResultJsonSerializer<T> {
final JSON json;
final T Function(JSON) fromJson;
PagedSearchResultJsonSerializer(this.json, this.fromJson);
}
class PagedSearchResult<T> extends Equatable {
/// Total number of available items
final int count;
@@ -46,12 +53,14 @@ class PagedSearchResult<T> extends Equatable {
});
factory PagedSearchResult.fromJson(
Map<dynamic, dynamic> json, T Function(JSON) fromJson) {
PagedSearchResultJsonSerializer<T> serializer) {
return PagedSearchResult(
count: json['count'],
next: json['next'],
previous: json['previous'],
results: List<JSON>.from(json['results']).map<T>(fromJson).toList(),
count: serializer.json['count'],
next: serializer.json['next'],
previous: serializer.json['previous'],
results: List<JSON>.from(serializer.json['results'])
.map<T>(serializer.fromJson)
.toList(),
);
}
@@ -138,8 +138,10 @@ class DocumentRepositoryImpl implements DocumentRepository {
body: json.encode(doc.toJson()),
headers: {"Content-Type": "application/json"}).timeout(requestTimeout);
if (response.statusCode == 200) {
return DocumentModel.fromJson(
jsonDecode(utf8.decode(response.bodyBytes)));
return compute(
DocumentModel.fromJson,
jsonDecode(utf8.decode(response.bodyBytes)) as JSON,
);
} else {
throw const ErrorMessage(ErrorCode.documentUpdateFailed);
}
@@ -152,11 +154,13 @@ class DocumentRepositoryImpl implements DocumentRepository {
Uri.parse("/api/documents/?$filterParams"),
);
if (response.statusCode == 200) {
final searchResult = PagedSearchResult.fromJson(
jsonDecode(utf8.decode(response.bodyBytes)),
DocumentModel.fromJson,
return compute(
PagedSearchResult.fromJson,
PagedSearchResultJsonSerializer<DocumentModel>(
jsonDecode(utf8.decode(response.bodyBytes)),
DocumentModel.fromJson,
),
);
return searchResult;
} else {
throw const ErrorMessage(ErrorCode.documentLoadFailed);
}
@@ -261,8 +265,10 @@ class DocumentRepositoryImpl implements DocumentRepository {
Future<DocumentMetaData> getMetaData(DocumentModel document) async {
final response = await httpClient
.get(Uri.parse("/api/documents/${document.id}/metadata/"));
return DocumentMetaData.fromJson(
jsonDecode(utf8.decode(response.bodyBytes)));
return compute(
DocumentMetaData.fromJson,
jsonDecode(utf8.decode(response.bodyBytes)) as JSON,
);
}
@override
@@ -280,10 +286,14 @@ class DocumentRepositoryImpl implements DocumentRepository {
final response = await httpClient
.get(Uri.parse("/api/documents/?more_like=$docId&pageSize=10"));
if (response.statusCode == 200) {
return PagedSearchResult<SimilarDocumentModel>.fromJson(
jsonDecode(utf8.decode(response.bodyBytes)),
SimilarDocumentModel.fromJson,
).results;
return (await compute(
PagedSearchResult<SimilarDocumentModel>.fromJson,
PagedSearchResultJsonSerializer(
jsonDecode(utf8.decode(response.bodyBytes)),
SimilarDocumentModel.fromJson,
),
))
.results;
}
throw const ErrorMessage(ErrorCode.similarQueryError);
}
@@ -198,10 +198,8 @@ class _DocumentDetailsPageState extends State<DocumentDetailsPage> {
child: Text(S
.of(context)
.documentDetailsPageAssignAsnButtonLabel),
onPressed: widget.allowEdit
? () => BlocProvider.of<DocumentsCubit>(context)
.assignAsn(document)
: null,
onPressed:
widget.allowEdit ? () => _assignAsn(document) : null,
),
),
_separator(),
@@ -233,6 +231,14 @@ class _DocumentDetailsPageState extends State<DocumentDetailsPage> {
);
}
Future<void> _assignAsn(DocumentModel document) async {
try {
await BlocProvider.of<DocumentsCubit>(context).assignAsn(document);
} on ErrorMessage catch (error) {
showError(context, error);
}
}
Widget _buildDocumentContentView(DocumentModel document, String? match) {
return SingleChildScrollView(
child: _DetailsItem(
@@ -392,21 +398,23 @@ class _DocumentDetailsPageState extends State<DocumentDetailsPage> {
);
}
Future<void> _onDelete(DocumentModel document) async {
showDialog(
context: context,
builder: (context) =>
DeleteDocumentConfirmationDialog(document: document))
.then((delete) {
if (delete ?? false) {
BlocProvider.of<DocumentsCubit>(context)
.removeDocument(document)
.then((value) {
Navigator.pop(context);
showSnackBar(context, S.of(context).documentDeleteSuccessMessage);
});
void _onDelete(DocumentModel document) async {
final delete = await showDialog(
context: context,
builder: (context) =>
DeleteDocumentConfirmationDialog(document: document),
) ??
false;
if (delete) {
try {
await BlocProvider.of<DocumentsCubit>(context).removeDocument(document);
showSnackBar(context, S.of(context).documentDeleteSuccessMessage);
} on ErrorMessage catch (error) {
showError(context, error);
} finally {
Navigator.pop(context);
}
});
}
}
Future<void> _onOpen(DocumentModel document) async {
@@ -3,6 +3,7 @@ import 'dart:typed_data';
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:flutter_form_builder/flutter_form_builder.dart';
import 'package:paperless_mobile/core/model/error_message.dart';
import 'package:paperless_mobile/di_initializer.dart';
import 'package:paperless_mobile/extensions/flutter_extensions.dart';
import 'package:paperless_mobile/features/documents/bloc/documents_cubit.dart';
@@ -76,10 +77,14 @@ class _DocumentEditPageState extends State<DocumentEditPage> {
setState(() {
_isSubmitLoading = true;
});
await getIt<DocumentsCubit>().updateDocument(updatedDocument);
Navigator.pop(context);
showSnackBar(
context, "Document successfully updated."); //TODO: INTL
try {
await getIt<DocumentsCubit>().updateDocument(updatedDocument);
showSnackBar(context, S.of(context).documentUpdateErrorMessage);
} on ErrorMessage catch (error) {
showError(context, error);
} finally {
Navigator.pop(context);
}
}
},
icon: const Icon(Icons.save),
@@ -44,13 +44,20 @@ class _DocumentsPageState extends State<DocumentsPage> {
@override
void initState() {
super.initState();
final documentsCubit = BlocProvider.of<DocumentsCubit>(context);
if (!documentsCubit.state.isLoaded) {
documentsCubit.loadDocuments();
if (!BlocProvider.of<DocumentsCubit>(context).state.isLoaded) {
_initDocuments();
}
_pagingController.addPageRequestListener(_loadNewPage);
}
Future<void> _initDocuments() async {
try {
BlocProvider.of<DocumentsCubit>(context).loadDocuments();
} on ErrorMessage catch (error) {
showError(context, error);
}
}
@override
void dispose() {
_pagingController.dispose();
@@ -64,17 +71,25 @@ class _DocumentsPageState extends State<DocumentsPage> {
if (pageCount <= pageKey + 1) {
_pagingController.nextPageKey = null;
}
documentsCubit.loadMore();
try {
await documentsCubit.loadMore();
} on ErrorMessage catch (error) {
showError(context, error);
}
}
void _onSelected(DocumentModel model) {
BlocProvider.of<DocumentsCubit>(context).toggleDocumentSelection(model);
}
Future<void> _onRefresh() {
final documentsCubit = BlocProvider.of<DocumentsCubit>(context);
return documentsCubit.updateFilter(
filter: documentsCubit.state.filter.copyWith(page: 1));
Future<void> _onRefresh() async {
try {
await BlocProvider.of<DocumentsCubit>(context).updateCurrentFilter(
(filter) => filter.copyWith(page: 1),
);
} on ErrorMessage catch (error) {
showError(context, error);
}
}
@override
@@ -86,9 +101,9 @@ class _DocumentsPageState extends State<DocumentsPage> {
_panelController.close();
return false;
}
final docBloc = BlocProvider.of<DocumentsCubit>(context);
if (docBloc.state.selection.isNotEmpty) {
docBloc.resetSelection();
final documentsCubit = BlocProvider.of<DocumentsCubit>(context);
if (documentsCubit.state.selection.isNotEmpty) {
documentsCubit.resetSelection();
return false;
}
return true;
@@ -1,7 +1,5 @@
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:paperless_mobile/core/logic/error_code_localization_mapper.dart';
import 'package:paperless_mobile/core/model/error_message.dart';
import 'package:paperless_mobile/core/widgets/empty_state.dart';
import 'package:paperless_mobile/extensions/flutter_extensions.dart';
import 'package:paperless_mobile/features/documents/bloc/documents_cubit.dart';
@@ -513,7 +513,7 @@ class _DocumentFilterPanelState extends State<DocumentFilterPanel> {
);
}
void _onApplyFilter() {
void _onApplyFilter() async {
if (_formKey.currentState?.saveAndValidate() ?? false) {
final v = _formKey.currentState!.value;
final docCubit = BlocProvider.of<DocumentsCubit>(context);
@@ -530,13 +530,15 @@ class _DocumentFilterPanelState extends State<DocumentFilterPanel> {
addedDateAfter: (v[fkAddedAt] as DateTimeRange?)?.start,
queryType: v[QueryTypeFormField.fkQueryType] as QueryType,
);
BlocProvider.of<DocumentsCubit>(context)
.updateFilter(filter: newFilter)
.then((value) {
try {
await BlocProvider.of<DocumentsCubit>(context)
.updateFilter(filter: newFilter);
BlocProvider.of<SavedViewCubit>(context).resetSelection();
FocusScope.of(context).unfocus();
widget.panelController.close();
});
} on ErrorMessage catch (error) {
showError(context, error);
}
}
}
@@ -74,14 +74,22 @@ class _DocumentsPageAppBarState extends State<DocumentsPageAppBar> {
void _onDelete(BuildContext context, DocumentsState documentsState) async {
final shouldDelete = await showDialog<bool>(
context: context,
builder: (context) => BulkDeleteConfirmationDialog(state: documentsState),
);
if (shouldDelete ?? false) {
BlocProvider.of<DocumentsCubit>(context)
.bulkRemoveDocuments(documentsState.selection)
.then((_) => showSnackBar(
context, S.of(context).documentsPageBulkDeleteSuccessfulText));
context: context,
builder: (context) =>
BulkDeleteConfirmationDialog(state: documentsState),
) ??
false;
if (shouldDelete) {
try {
await BlocProvider.of<DocumentsCubit>(context)
.bulkRemoveDocuments(documentsState.selection);
showSnackBar(
context,
S.of(context).documentsPageBulkDeleteSuccessfulText,
);
} on ErrorMessage catch (error) {
showError(context, error);
}
}
}
@@ -78,22 +78,32 @@ class SavedViewSelectionWidget extends StatelessWidget {
final newView = await Navigator.of(context).push<SavedView?>(
MaterialPageRoute(
builder: (context) => AddSavedViewPage(
currentFilter: getIt<DocumentsCubit>().state.filter),
currentFilter: getIt<DocumentsCubit>().state.filter,
),
),
);
if (newView != null) {
BlocProvider.of<SavedViewCubit>(context).add(newView);
try {
await BlocProvider.of<SavedViewCubit>(context).add(newView);
} on ErrorMessage catch (error) {
showError(context, error);
}
}
}
void _onSelected(bool isSelected, BuildContext context, SavedView view) {
if (isSelected) {
BlocProvider.of<DocumentsCubit>(context)
.updateFilter(filter: view.toDocumentFilter());
BlocProvider.of<SavedViewCubit>(context).selectView(view);
} else {
BlocProvider.of<DocumentsCubit>(context).updateFilter();
BlocProvider.of<SavedViewCubit>(context).selectView(null);
void _onSelected(
bool isSelected, BuildContext context, SavedView view) async {
try {
if (isSelected) {
BlocProvider.of<DocumentsCubit>(context)
.updateFilter(filter: view.toDocumentFilter());
BlocProvider.of<SavedViewCubit>(context).selectView(view);
} else {
BlocProvider.of<DocumentsCubit>(context).updateFilter();
BlocProvider.of<SavedViewCubit>(context).selectView(null);
}
} on ErrorMessage catch (error) {
showError(context, error);
}
}
@@ -105,7 +115,11 @@ class SavedViewSelectionWidget extends StatelessWidget {
) ??
false;
if (delete) {
BlocProvider.of<SavedViewCubit>(context).remove(view);
try {
BlocProvider.of<SavedViewCubit>(context).remove(view);
} on ErrorMessage catch (error) {
showError(context, error);
}
}
}
}
@@ -43,11 +43,18 @@ class _SortDocumentsButtonState extends State<SortDocumentsButton> {
),
onPressed: () async {
setState(() => _isLoading = true);
BlocProvider.of<DocumentsCubit>(context)
.updateFilter(
filter: state.filter
.copyWith(sortOrder: state.filter.sortOrder.toggle()))
.whenComplete(() => setState(() => _isLoading = false));
try {
await BlocProvider.of<DocumentsCubit>(context)
.updateCurrentFilter(
(filter) => filter.copyWith(
sortOrder: state.filter.sortOrder.toggle(),
),
);
} on ErrorMessage catch (error) {
showError(context, error);
} finally {
setState(() => _isLoading = false);
}
},
);
}
+40 -45
View File
@@ -1,13 +1,12 @@
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:paperless_mobile/core/bloc/connectivity_cubit.dart';
import 'package:paperless_mobile/core/bloc/global_error_cubit.dart';
import 'package:paperless_mobile/core/logic/error_code_localization_mapper.dart';
import 'package:paperless_mobile/core/model/error_message.dart';
import 'package:paperless_mobile/core/widgets/offline_banner.dart';
import 'package:paperless_mobile/di_initializer.dart';
import 'package:paperless_mobile/features/documents/bloc/documents_cubit.dart';
import 'package:paperless_mobile/features/documents/bloc/saved_view_cubit.dart';
import 'package:paperless_mobile/features/documents/model/saved_view.model.dart';
import 'package:paperless_mobile/features/documents/view/pages/documents_page.dart';
import 'package:paperless_mobile/features/home/view/widget/bottom_navigation_bar.dart';
import 'package:paperless_mobile/features/home/view/widget/info_drawer.dart';
@@ -38,55 +37,51 @@ class _HomePageState extends State<HomePage> {
@override
Widget build(BuildContext context) {
return BlocListener<GlobalErrorCubit, GlobalErrorState>(
return BlocConsumer<ConnectivityCubit, ConnectivityState>(
//Only re-initialize data if the connectivity changed from not connected to connected
listenWhen: (previous, current) => current == ConnectivityState.connected,
listener: (context, state) {
if (state.hasError) {
showSnackBar(context, translateError(context, state.error!.code));
}
initializeLabelData(context);
},
child: BlocConsumer<ConnectivityCubit, ConnectivityState>(
//Only re-initialize data if the connectivity changed from not connected to connected
listenWhen: (previous, current) =>
current == ConnectivityState.connected,
listener: (context, state) {
initializeLabelData(context);
},
builder: (context, connectivityState) {
return Scaffold(
appBar: connectivityState == ConnectivityState.connected
? null
: const OfflineBanner(),
key: rootScaffoldKey,
bottomNavigationBar: BottomNavBar(
selectedIndex: _currentIndex,
onNavigationChanged: (index) =>
setState(() => _currentIndex = index),
builder: (context, connectivityState) {
return Scaffold(
appBar: connectivityState == ConnectivityState.connected
? null
: const OfflineBanner(),
key: rootScaffoldKey,
bottomNavigationBar: BottomNavBar(
selectedIndex: _currentIndex,
onNavigationChanged: (index) =>
setState(() => _currentIndex = index),
),
drawer: const InfoDrawer(),
body: [
MultiBlocProvider(
providers: [
BlocProvider.value(value: getIt<DocumentsCubit>()),
],
child: const DocumentsPage(),
),
drawer: const InfoDrawer(),
body: [
MultiBlocProvider(
providers: [
BlocProvider.value(value: getIt<DocumentsCubit>()),
],
child: const DocumentsPage(),
),
BlocProvider.value(
value: getIt<DocumentScannerCubit>(),
child: const ScannerPage(),
),
const LabelsPage(),
][_currentIndex],
);
},
),
BlocProvider.value(
value: getIt<DocumentScannerCubit>(),
child: const ScannerPage(),
),
const LabelsPage(),
][_currentIndex],
);
},
);
}
initializeLabelData(BuildContext context) {
BlocProvider.of<DocumentTypeCubit>(context).initialize();
BlocProvider.of<CorrespondentCubit>(context).initialize();
BlocProvider.of<TagCubit>(context).initialize();
BlocProvider.of<StoragePathCubit>(context).initialize();
BlocProvider.of<SavedViewCubit>(context).initialize();
try {
BlocProvider.of<DocumentTypeCubit>(context).initialize();
BlocProvider.of<CorrespondentCubit>(context).initialize();
BlocProvider.of<TagCubit>(context).initialize();
BlocProvider.of<StoragePathCubit>(context).initialize();
BlocProvider.of<SavedViewCubit>(context).initialize();
} on ErrorMessage catch (error) {
showError(context, error);
}
}
}
+11 -11
View File
@@ -1,8 +1,6 @@
import 'dart:ui';
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:paperless_mobile/core/bloc/global_error_cubit.dart';
import 'package:paperless_mobile/core/model/error_message.dart';
import 'package:paperless_mobile/features/settings/bloc/application_settings_cubit.dart';
import 'package:paperless_mobile/di_initializer.dart';
import 'package:paperless_mobile/features/labels/correspondent/bloc/correspondents_cubit.dart';
@@ -129,14 +127,16 @@ class InfoDrawer extends StatelessWidget {
leading: const Icon(Icons.logout),
title: Text(S.of(context).appDrawerLogoutLabel),
onTap: () {
// Clear all bloc data
BlocProvider.of<AuthenticationCubit>(context).logout();
getIt<DocumentsCubit>().reset();
getIt<CorrespondentCubit>().reset();
getIt<DocumentTypeCubit>().reset();
getIt<TagCubit>().reset();
getIt<DocumentScannerCubit>().reset();
getIt<GlobalErrorCubit>().reset();
try {
BlocProvider.of<AuthenticationCubit>(context).logout();
getIt<DocumentsCubit>().reset();
getIt<CorrespondentCubit>().reset();
getIt<DocumentTypeCubit>().reset();
getIt<TagCubit>().reset();
getIt<DocumentScannerCubit>().reset();
} on ErrorMessage catch (error) {
showError(context, error);
}
},
),
const Divider(),
@@ -4,7 +4,7 @@ import 'package:injectable/injectable.dart';
@singleton
class CorrespondentCubit extends LabelCubit<Correspondent> {
CorrespondentCubit(super.metaDataService, super.errorCubit);
CorrespondentCubit(super.metaDataService);
@override
Future<void> initialize() async {
@@ -18,21 +18,28 @@ class EditCorrespondentPage extends StatelessWidget {
return EditLabelPage<Correspondent>(
label: correspondent,
onSubmit: BlocProvider.of<CorrespondentCubit>(context).replace,
onDelete: (correspondent) => _onDelete(correspondent, context),
onDelete: (correspondent) => _onDelete(context, correspondent),
fromJson: Correspondent.fromJson,
);
}
Future<void> _onDelete(
Correspondent correspondent, BuildContext context) async {
await BlocProvider.of<CorrespondentCubit>(context).remove(correspondent);
final cubit = BlocProvider.of<DocumentsCubit>(context);
if (cubit.state.filter.correspondent.id == correspondent.id) {
cubit.updateFilter(
filter: cubit.state.filter
.copyWith(correspondent: const CorrespondentQuery.unset()),
);
BuildContext context,
Correspondent correspondent,
) async {
try {
await BlocProvider.of<CorrespondentCubit>(context).remove(correspondent);
final cubit = BlocProvider.of<DocumentsCubit>(context);
if (cubit.state.filter.correspondent.id == correspondent.id) {
await cubit.updateCurrentFilter(
(filter) => filter.copyWith(
correspondent: const CorrespondentQuery.unset(),
),
);
}
Navigator.pop(context);
} on ErrorMessage catch (error) {
showError(context, error);
}
Navigator.pop(context);
}
}
@@ -1,10 +1,12 @@
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:paperless_mobile/core/model/error_message.dart';
import 'package:paperless_mobile/di_initializer.dart';
import 'package:paperless_mobile/features/documents/bloc/documents_cubit.dart';
import 'package:paperless_mobile/features/documents/model/query_parameters/correspondent_query.dart';
import 'package:paperless_mobile/features/labels/correspondent/bloc/correspondents_cubit.dart';
import 'package:paperless_mobile/features/labels/correspondent/model/correspondent.model.dart';
import 'package:paperless_mobile/util.dart';
class CorrespondentWidget extends StatelessWidget {
final int? correspondentId;
@@ -44,17 +46,21 @@ class CorrespondentWidget extends StatelessWidget {
void _addCorrespondentToFilter(BuildContext context) {
final cubit = BlocProvider.of<DocumentsCubit>(context);
if (cubit.state.filter.correspondent.id == correspondentId) {
cubit.updateCurrentFilter(
(filter) =>
filter.copyWith(correspondent: const CorrespondentQuery.unset()),
);
} else {
cubit.updateCurrentFilter(
(filter) => filter.copyWith(
correspondent: CorrespondentQuery.fromId(correspondentId)),
);
try {
if (cubit.state.filter.correspondent.id == correspondentId) {
cubit.updateCurrentFilter(
(filter) =>
filter.copyWith(correspondent: const CorrespondentQuery.unset()),
);
} else {
cubit.updateCurrentFilter(
(filter) => filter.copyWith(
correspondent: CorrespondentQuery.fromId(correspondentId)),
);
}
afterSelected?.call();
} on ErrorMessage catch (error) {
showError(context, error);
}
afterSelected?.call();
}
}
@@ -1,14 +1,15 @@
import 'package:flutter/foundation.dart';
import 'package:paperless_mobile/core/bloc/label_cubit.dart';
import 'package:paperless_mobile/features/labels/document_type/model/document_type.model.dart';
import 'package:injectable/injectable.dart';
@singleton
class DocumentTypeCubit extends LabelCubit<DocumentType> {
DocumentTypeCubit(super.metaDataService, super.errorCubit);
DocumentTypeCubit(super.metaDataService);
@override
Future<void> initialize() async {
return labelRepository.getDocumentTypes().then(loadFrom);
labelRepository.getDocumentTypes().then(loadFrom);
}
@override
@@ -1,9 +1,11 @@
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:paperless_mobile/core/model/error_message.dart';
import 'package:paperless_mobile/features/documents/bloc/documents_cubit.dart';
import 'package:paperless_mobile/features/documents/model/query_parameters/document_type_query.dart';
import 'package:paperless_mobile/features/labels/document_type/bloc/document_type_cubit.dart';
import 'package:paperless_mobile/features/labels/document_type/model/document_type.model.dart';
import 'package:paperless_mobile/util.dart';
class DocumentTypeWidget extends StatelessWidget {
final int? documentTypeId;
@@ -39,17 +41,21 @@ class DocumentTypeWidget extends StatelessWidget {
void _addDocumentTypeToFilter(BuildContext context) {
final cubit = BlocProvider.of<DocumentsCubit>(context);
if (cubit.state.filter.documentType.id == documentTypeId) {
cubit.updateCurrentFilter(
(filter) =>
filter.copyWith(documentType: const DocumentTypeQuery.unset()),
);
} else {
cubit.updateCurrentFilter(
(filter) => filter.copyWith(
documentType: DocumentTypeQuery.fromId(documentTypeId)),
);
try {
if (cubit.state.filter.documentType.id == documentTypeId) {
cubit.updateCurrentFilter(
(filter) =>
filter.copyWith(documentType: const DocumentTypeQuery.unset()),
);
} else {
cubit.updateCurrentFilter(
(filter) => filter.copyWith(
documentType: DocumentTypeQuery.fromId(documentTypeId)),
);
}
afterSelected?.call();
} on ErrorMessage catch (error) {
showError(context, error);
}
afterSelected?.call();
}
}
@@ -4,7 +4,7 @@ import 'package:paperless_mobile/features/labels/storage_path/model/storage_path
@singleton
class StoragePathCubit extends LabelCubit<StoragePath> {
StoragePathCubit(super.metaDataService, super.errorCubit);
StoragePathCubit(super.metaDataService);
@override
Future<void> initialize() async {
@@ -32,13 +32,19 @@ class EditStoragePathPage extends StatelessWidget {
}
Future<void> _onDelete(StoragePath path, BuildContext context) async {
await BlocProvider.of<StoragePathCubit>(context).remove(path);
final cubit = BlocProvider.of<DocumentsCubit>(context);
if (cubit.state.filter.storagePath.id == path.id) {
cubit.updateFilter(
filter: cubit.state.filter
.copyWith(storagePath: const StoragePathQuery.unset()));
try {
await BlocProvider.of<StoragePathCubit>(context).remove(path);
final cubit = BlocProvider.of<DocumentsCubit>(context);
if (cubit.state.filter.storagePath.id == path.id) {
cubit.updateCurrentFilter(
(filter) => filter.copyWith(
storagePath: const StoragePathQuery.unset(),
),
);
}
Navigator.pop(context);
} on ErrorMessage catch (error) {
showError(context, error);
}
Navigator.pop(context);
}
}
@@ -1,9 +1,11 @@
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:paperless_mobile/core/model/error_message.dart';
import 'package:paperless_mobile/features/documents/bloc/documents_cubit.dart';
import 'package:paperless_mobile/features/documents/model/query_parameters/storage_path_query.dart';
import 'package:paperless_mobile/features/labels/storage_path/bloc/storage_path_cubit.dart';
import 'package:paperless_mobile/features/labels/storage_path/model/storage_path.model.dart';
import 'package:paperless_mobile/util.dart';
class StoragePathWidget extends StatelessWidget {
final int? pathId;
@@ -43,17 +45,21 @@ class StoragePathWidget extends StatelessWidget {
void _addStoragePathToFilter(BuildContext context) {
final cubit = BlocProvider.of<DocumentsCubit>(context);
if (cubit.state.filter.correspondent.id == pathId) {
cubit.updateCurrentFilter(
(filter) =>
filter.copyWith(storagePath: const StoragePathQuery.unset()),
);
} else {
cubit.updateCurrentFilter(
(filter) =>
filter.copyWith(storagePath: StoragePathQuery.fromId(pathId)),
);
try {
if (cubit.state.filter.correspondent.id == pathId) {
cubit.updateCurrentFilter(
(filter) =>
filter.copyWith(storagePath: const StoragePathQuery.unset()),
);
} else {
cubit.updateCurrentFilter(
(filter) =>
filter.copyWith(storagePath: StoragePathQuery.fromId(pathId)),
);
}
afterSelected?.call();
} on ErrorMessage catch (error) {
showError(context, error);
}
afterSelected?.call();
}
}
@@ -4,7 +4,7 @@ import 'package:injectable/injectable.dart';
@singleton
class TagCubit extends LabelCubit<Tag> {
TagCubit(super.metaDataService, super.errorCubit);
TagCubit(super.metaDataService);
@override
Future<void> initialize() async {
+16 -3
View File
@@ -10,6 +10,7 @@ class Tag extends Label {
static const colorKey = 'color';
static const isInboxTagKey = 'is_inbox_tag';
static const textColorKey = 'text_color';
static const legacyColourKey = 'colour';
final Color? color;
final Color? textColor;
@@ -31,11 +32,23 @@ class Tag extends Label {
Tag.fromJson(JSON json)
: isInboxTag = json[isInboxTagKey],
textColor = Color(_colorStringToInt(json[textColorKey]) ?? 0),
color = (json[colorKey] is Color)
? json[colorKey]
: Color(_colorStringToInt(json[colorKey]) ?? 0),
color = _parseColorFromJson(json),
super.fromJson(json);
///
/// The `color` field of the json object can either be of type [Color] or a hex [String].
/// Since API version 2, the old attribute `colour` has been replaced with `color`.
///
static Color _parseColorFromJson(JSON json) {
if (json.containsKey(legacyColourKey)) {
return Color(_colorStringToInt(json[legacyColourKey]) ?? 0);
}
if (json[colorKey] is Color) {
return json[colorKey];
}
return Color(_colorStringToInt(json[colorKey]) ?? 0);
}
@override
String toString() {
return name;
@@ -43,18 +43,22 @@ class EditTagPage extends StatelessWidget {
}
Future<void> _onDelete(Tag tag, BuildContext context) async {
await BlocProvider.of<TagCubit>(context).remove(tag);
final cubit = BlocProvider.of<DocumentsCubit>(context);
final currentFilter = cubit.state.filter;
late DocumentFilter updatedFilter = currentFilter;
if (currentFilter.tags.ids.contains(tag.id)) {
updatedFilter = currentFilter.copyWith(
tags: TagsQuery.fromIds(
currentFilter.tags.ids.where((tagId) => tagId != tag.id).toList(),
),
);
try {
await BlocProvider.of<TagCubit>(context).remove(tag);
final cubit = BlocProvider.of<DocumentsCubit>(context);
final currentFilter = cubit.state.filter;
late DocumentFilter updatedFilter = currentFilter;
if (currentFilter.tags.ids.contains(tag.id)) {
updatedFilter = currentFilter.copyWith(
tags: TagsQuery.fromIds(
currentFilter.tags.ids.where((tagId) => tagId != tag.id).toList(),
),
);
}
cubit.updateFilter(filter: updatedFilter);
Navigator.pop(context);
} on ErrorMessage catch (error) {
showError(context, error);
}
cubit.updateFilter(filter: updatedFilter);
Navigator.pop(context);
}
}
@@ -38,22 +38,27 @@ class TagWidget extends StatelessWidget {
void _addTagToFilter(BuildContext context) {
final cubit = BlocProvider.of<DocumentsCubit>(context);
if (cubit.state.filter.tags.ids.contains(tag.id)) {
cubit.updateCurrentFilter(
(filter) => filter.copyWith(
tags: TagsQuery.fromIds(
cubit.state.filter.tags.ids.where((id) => id != tag.id).toList()),
),
);
} else {
cubit.updateCurrentFilter(
(filter) => filter.copyWith(
tags: TagsQuery.fromIds([...cubit.state.filter.tags.ids, tag.id!]),
),
);
}
if (afterTagTapped != null) {
afterTagTapped!();
try {
if (cubit.state.filter.tags.ids.contains(tag.id)) {
cubit.updateCurrentFilter(
(filter) => filter.copyWith(
tags: TagsQuery.fromIds(cubit.state.filter.tags.ids
.where((id) => id != tag.id)
.toList()),
),
);
} else {
cubit.updateCurrentFilter(
(filter) => filter.copyWith(
tags: TagsQuery.fromIds([...cubit.state.filter.tags.ids, tag.id!]),
),
);
}
if (afterTagTapped != null) {
afterTagTapped!();
}
} on ErrorMessage catch (error) {
showError(context, error);
}
}
}
@@ -3,11 +3,13 @@ import 'dart:async';
import 'package:flutter/material.dart';
import 'package:flutter_form_builder/flutter_form_builder.dart';
import 'package:form_builder_validators/form_builder_validators.dart';
import 'package:paperless_mobile/core/model/error_message.dart';
import 'package:paperless_mobile/core/type/types.dart';
import 'package:paperless_mobile/extensions/flutter_extensions.dart';
import 'package:paperless_mobile/features/labels/document_type/model/matching_algorithm.dart';
import 'package:paperless_mobile/features/labels/model/label.model.dart';
import 'package:paperless_mobile/generated/l10n.dart';
import 'package:paperless_mobile/util.dart';
class EditLabelPage<T extends Label> extends StatefulWidget {
final T label;
@@ -144,6 +146,8 @@ class _EditLabelPageState<T extends Label> extends State<EditLabelPage<T>> {
Navigator.pop(context);
} on PaperlessValidationErrors catch (errorMessages) {
setState(() => _errors = errorMessages);
} on ErrorMessage catch (error) {
showError(context, error);
}
}
}
@@ -1,6 +1,5 @@
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:paperless_mobile/core/bloc/global_error_cubit.dart';
import 'package:paperless_mobile/core/bloc/label_bloc_provider.dart';
import 'package:paperless_mobile/core/logic/error_code_localization_mapper.dart';
import 'package:paperless_mobile/core/model/error_message.dart';
@@ -53,10 +52,9 @@ class LabelItem<T extends Label> extends StatelessWidget {
MaterialPageRoute(
builder: (context) => LabelBlocProvider(
child: BlocProvider(
create: (context) => DocumentsCubit(
getIt<DocumentRepository>(),
getIt<GlobalErrorCubit>())
..updateFilter(filter: filter),
create: (context) =>
DocumentsCubit(getIt<DocumentRepository>())
..updateFilter(filter: filter),
child: LinkedDocumentsPreview(filter: filter),
),
),
@@ -1,7 +1,7 @@
import 'dart:developer';
import 'dart:io';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:paperless_mobile/core/bloc/global_error_cubit.dart';
import 'package:paperless_mobile/core/model/error_message.dart';
import 'package:paperless_mobile/core/store/local_vault.dart';
import 'package:paperless_mobile/di_initializer.dart';
@@ -17,13 +17,11 @@ const authenticationKey = "authentication";
@singleton
class AuthenticationCubit extends Cubit<AuthenticationState> {
final LocalVault localStore;
final GlobalErrorCubit errorCubit;
final AuthenticationService authenticationService;
AuthenticationCubit(
this.localStore,
this.authenticationService,
this.errorCubit,
) : super(AuthenticationState.initial);
Future<void> initialize() {
@@ -34,7 +32,6 @@ class AuthenticationCubit extends Cubit<AuthenticationState> {
required UserCredentials credentials,
required String serverUrl,
ClientCertificate? clientCertificate,
bool propagateEventOnError = true,
}) async {
assert(credentials.username != null && credentials.password != null);
try {
@@ -75,49 +72,37 @@ class AuthenticationCubit extends Cubit<AuthenticationState> {
} on TlsException catch (_) {
const error =
ErrorMessage(ErrorCode.invalidClientCertificateConfiguration);
if (propagateEventOnError) {
errorCubit.add(error);
}
throw error;
} on ErrorMessage catch (error) {
if (propagateEventOnError) {
errorCubit.add(error);
} on SocketException catch (err) {
if (err.message.contains("connection timed out")) {
throw const ErrorMessage(ErrorCode.requestTimedOut);
} else {
throw ErrorMessage.unknown();
}
rethrow;
}
}
Future<void> restoreSessionState({
bool propagateEventOnError = true,
}) async {
try {
final storedAuth = await localStore.loadAuthenticationInformation();
final appSettings = await localStore.loadApplicationSettings() ??
ApplicationSettingsState.defaultSettings;
Future<void> restoreSessionState() async {
final storedAuth = await localStore.loadAuthenticationInformation();
final appSettings = await localStore.loadApplicationSettings() ??
ApplicationSettingsState.defaultSettings;
if (storedAuth == null || !storedAuth.isValid) {
if (storedAuth == null || !storedAuth.isValid) {
emit(AuthenticationState(isAuthenticated: false, wasLoginStored: false));
} else {
if (!appSettings.isLocalAuthenticationEnabled ||
await authenticationService
.authenticateLocalUser("Authenticate to log back in")) {
registerSecurityContext(storedAuth.clientCertificate);
emit(
AuthenticationState(isAuthenticated: false, wasLoginStored: false));
AuthenticationState(
isAuthenticated: true,
wasLoginStored: true,
authentication: storedAuth,
),
);
} else {
if (!appSettings.isLocalAuthenticationEnabled ||
await authenticationService
.authenticateLocalUser("Authenticate to log back in")) {
registerSecurityContext(storedAuth.clientCertificate);
emit(
AuthenticationState(
isAuthenticated: true,
wasLoginStored: true,
authentication: storedAuth,
),
);
} else {
emit(AuthenticationState(
isAuthenticated: false, wasLoginStored: true));
}
}
} on ErrorMessage catch (error) {
if (propagateEventOnError) {
errorCubit.add(error);
emit(AuthenticationState(isAuthenticated: false, wasLoginStored: true));
}
}
}
@@ -26,10 +26,19 @@ class AuthenticationService {
required String password,
required String serverUrl,
}) async {
final response = await httpClient.post(
Uri.parse("/api/token/"),
body: {"username": username, "password": password},
);
late Response response;
try {
response = await httpClient.post(
Uri.parse("/api/token/"),
body: {"username": username, "password": password},
);
} on FormatException catch (e) {
final source = e.source;
if (source is String &&
source.contains("400 No required SSL certificate was sent")) {
throw const ErrorMessage(ErrorCode.missingClientCertificate);
}
}
if (response.statusCode == 200) {
final data = jsonDecode(utf8.decode(response.bodyBytes));
return data['token'];
+20 -11
View File
@@ -1,12 +1,14 @@
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:flutter_form_builder/flutter_form_builder.dart';
import 'package:paperless_mobile/core/model/error_message.dart';
import 'package:paperless_mobile/extensions/flutter_extensions.dart';
import 'package:paperless_mobile/features/login/bloc/authentication_cubit.dart';
import 'package:paperless_mobile/features/login/view/widgets/client_certificate_form_field.dart';
import 'package:paperless_mobile/features/login/view/widgets/server_address_form_field.dart';
import 'package:paperless_mobile/features/login/view/widgets/user_credentials_form_field.dart';
import 'package:paperless_mobile/generated/l10n.dart';
import 'package:paperless_mobile/util.dart';
class LoginPage extends StatefulWidget {
const LoginPage({Key? key}) : super(key: key);
@@ -72,7 +74,8 @@ class _LoginPageState extends State<LoginPage> {
return ElevatedButton(
style: ButtonStyle(
backgroundColor: MaterialStatePropertyAll(
Theme.of(context).colorScheme.primaryContainer),
Theme.of(context).colorScheme.primaryContainer,
),
elevation: const MaterialStatePropertyAll(0),
),
onPressed: _login,
@@ -82,19 +85,25 @@ class _LoginPageState extends State<LoginPage> {
);
}
void _login() {
void _login() async {
FocusScope.of(context).unfocus();
if (_formKey.currentState?.saveAndValidate() ?? false) {
setState(() => _isLoginLoading = true);
final form = _formKey.currentState?.value;
BlocProvider.of<AuthenticationCubit>(context)
.login(
credentials: form?[UserCredentialsFormField.fkCredentials],
serverUrl: form?[ServerAddressFormField.fkServerAddress],
clientCertificate:
form?[ClientCertificateFormField.fkClientCertificate],
)
.whenComplete(() => setState(() => _isLoginLoading = false));
final form = _formKey.currentState!.value;
try {
await BlocProvider.of<AuthenticationCubit>(context).login(
credentials: form[UserCredentialsFormField.fkCredentials],
serverUrl: form[ServerAddressFormField.fkServerAddress],
clientCertificate:
form[ClientCertificateFormField.fkClientCertificate],
);
} on ErrorMessage catch (error) {
showError(context, error);
} catch (unknownError) {
showSnackBar(context, unknownError.toString());
} finally {
setState(() => _isLoginLoading = false);
}
}
}
}
@@ -1,5 +1,7 @@
import 'dart:developer';
import 'dart:io';
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:paperless_mobile/core/model/error_message.dart';
@@ -20,15 +22,22 @@ class DocumentScannerCubit extends Cubit<List<File>> {
scans.removeAt(fileIndex);
emit(scans);
} catch (_) {
addError(const ErrorMessage(ErrorCode.scanRemoveFailed));
throw const ErrorMessage(ErrorCode.scanRemoveFailed);
}
}
void reset() {
for (final doc in state) {
doc.deleteSync();
try {
for (final doc in state) {
doc.deleteSync();
if (kDebugMode) {
log('[ScannerCubit]: Removed ${doc.path}');
}
}
imageCache.clear();
emit(initialState);
} catch (_) {
throw const ErrorMessage(ErrorCode.scanRemoveFailed);
}
imageCache.clear();
emit(initialState);
}
}
@@ -30,10 +30,11 @@ import 'package:intl/intl.dart';
class DocumentUploadPage extends StatefulWidget {
final Uint8List fileBytes;
final void Function()? afterUpload;
const DocumentUploadPage({
Key? key,
required this.fileBytes,
this.afterUpload,
}) : super(key: key);
@override
@@ -184,50 +185,33 @@ class _DocumentUploadPageState extends State<DocumentUploadPage> {
}
void _onSubmit() async {
_formKey.currentState?.save();
if (_formKey.currentState?.validate() ?? false) {
if (_formKey.currentState?.saveAndValidate() ?? false) {
try {
setState(() {
_isUploadLoading = true;
});
setState(() => _isUploadLoading = true);
final fv = _formKey.currentState!.value;
final createdAt = fv[DocumentModel.createdKey] as DateTime?;
final title = fv[DocumentModel.titleKey] as String;
final docType = fv[DocumentModel.documentTypeKey] as IdQueryParameter;
final tags = fv[DocumentModel.tagsKey] as TagsQuery;
final correspondent =
fv[DocumentModel.correspondentKey] as IdQueryParameter;
await BlocProvider.of<DocumentsCubit>(context).addDocument(
widget.fileBytes,
_formKey.currentState?.value[fkFileName],
onConsumptionFinished: (document) {
ScaffoldMessenger.of(rootScaffoldKey.currentContext!).showSnackBar(
SnackBar(
action: SnackBarAction(
onPressed: () {
getIt<DocumentsCubit>().reloadDocuments();
},
label: S
.of(context)
.documentUploadProcessingSuccessfulReloadActionText,
),
content:
Text(S.of(context).documentUploadProcessingSuccessfulText),
),
);
},
title: _formKey.currentState?.value[DocumentModel.titleKey],
documentType: (_formKey.currentState
?.value[DocumentModel.documentTypeKey] as IdQueryParameter)
.id,
correspondent: (_formKey.currentState
?.value[DocumentModel.correspondentKey] as IdQueryParameter)
.id,
tags:
(_formKey.currentState?.value[DocumentModel.tagsKey] as TagsQuery)
.ids,
createdAt: (_formKey.currentState?.value[DocumentModel.createdKey]
as DateTime?),
onConsumptionFinished: _onConsumptionFinished,
title: title,
documentType: docType.id,
correspondent: correspondent.id,
tags: tags.ids,
createdAt: createdAt,
);
setState(() {
_isUploadLoading = false;
});
getIt<DocumentScannerCubit>().reset();
Navigator.pop(context);
getIt<DocumentScannerCubit>().reset(); //TODO: Access via provider
showSnackBar(context, S.of(context).documentUploadSuccessText);
Navigator.pop(context);
widget.afterUpload?.call();
} on ErrorMessage catch (error) {
showError(context, error);
} on PaperlessValidationErrors catch (errorMessages) {
@@ -236,9 +220,28 @@ class _DocumentUploadPageState extends State<DocumentUploadPage> {
showSnackBar(context, other.toString());
} finally {
setState(() {
_isUploadLoading = true;
_isUploadLoading = false;
});
}
}
}
void _onConsumptionFinished(document) {
ScaffoldMessenger.of(rootScaffoldKey.currentContext!).showSnackBar(
SnackBar(
action: SnackBarAction(
onPressed: () async {
try {
getIt<DocumentsCubit>().reloadDocuments();
} on ErrorMessage catch (error) {
showError(context, error);
}
},
label:
S.of(context).documentUploadProcessingSuccessfulReloadActionText,
),
content: Text(S.of(context).documentUploadProcessingSuccessfulText),
),
);
}
}
+46 -23
View File
@@ -1,3 +1,4 @@
import 'dart:developer' as dev;
import 'dart:io';
import 'dart:math';
@@ -8,7 +9,9 @@ import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:mime/mime.dart';
import 'package:paperless_mobile/core/bloc/label_bloc_provider.dart';
import 'package:paperless_mobile/core/global/constants.dart';
import 'package:paperless_mobile/core/model/error_message.dart';
import 'package:paperless_mobile/core/service/file_service.dart';
import 'package:paperless_mobile/di_initializer.dart';
import 'package:paperless_mobile/features/documents/bloc/documents_cubit.dart';
import 'package:paperless_mobile/features/home/view/widget/info_drawer.dart';
@@ -30,16 +33,9 @@ class ScannerPage extends StatefulWidget {
class _ScannerPageState extends State<ScannerPage>
with SingleTickerProviderStateMixin {
static const _supportedExtensions = [
'pdf',
'png',
'tiff',
'gif',
'jpg',
'jpeg'
];
late final AnimationController _fabPulsingController;
late final Animation _animation;
@override
void initState() {
super.initState();
@@ -47,9 +43,7 @@ class _ScannerPageState extends State<ScannerPage>
AnimationController(vsync: this, duration: const Duration(seconds: 1))
..repeat(reverse: true);
_animation = Tween(begin: 1.0, end: 1.2).animate(_fabPulsingController)
..addListener(() {
setState(() {});
});
..addListener(() => setState((() {})));
}
@override
@@ -101,7 +95,9 @@ class _ScannerPageState extends State<ScannerPage>
BlocBuilder<DocumentScannerCubit, List<File>>(
builder: (context, state) {
return IconButton(
onPressed: state.isEmpty ? null : () => _export(context),
onPressed: state.isEmpty
? null
: () => _onPrepareDocumentUpload(context),
icon: const Icon(Icons.done),
tooltip: S.of(context).documentScannerPageUploadButtonTooltip,
);
@@ -113,17 +109,31 @@ class _ScannerPageState extends State<ScannerPage>
void _openDocumentScanner(BuildContext context) async {
await _requestCameraPermissions();
final imagePath = await EdgeDetection.detectEdge;
if (imagePath == null) {
final file = await FileService.allocateTemporaryFile(
PaperlessDirectoryType.scans,
extension: 'jpeg',
);
if (kDebugMode) {
dev.log('[ScannerPage] Created temporary file: ${file.path}');
}
final success = await EdgeDetection.detectEdge(file.path);
if (!success) {
if (kDebugMode) {
dev.log(
'[ScannerPage] Scan either not successful or canceled by user.');
}
return;
}
final file = File(imagePath);
if (kDebugMode) {
dev.log('[ScannerPage] Wrote image to temporary file: ${file.path}');
}
BlocProvider.of<DocumentScannerCubit>(context).addScan(file);
}
void _export(BuildContext context) async {
void _onPrepareDocumentUpload(BuildContext context) async {
final doc = _buildDocumentFromImageFiles(
BlocProvider.of<DocumentScannerCubit>(context).state);
BlocProvider.of<DocumentScannerCubit>(context).state,
);
final bytes = await doc.save();
Navigator.of(context).push(
MaterialPageRoute(
@@ -187,8 +197,14 @@ class _ScannerPageState extends State<ScannerPage>
itemBuilder: (context, index) {
return GridImageItemWidget(
file: scans[index],
onDelete: () => BlocProvider.of<DocumentScannerCubit>(context)
.removeScan(index),
onDelete: () async {
try {
BlocProvider.of<DocumentScannerCubit>(context)
.removeScan(index);
} on ErrorMessage catch (error) {
showError(context, error);
}
},
index: index,
totalNumberOfFiles: scans.length,
);
@@ -196,25 +212,32 @@ class _ScannerPageState extends State<ScannerPage>
}
void _reset(BuildContext context) {
BlocProvider.of<DocumentScannerCubit>(context).reset();
try {
BlocProvider.of<DocumentScannerCubit>(context).reset();
} on ErrorMessage catch (error) {
showError(context, error);
}
}
Future<void> _requestCameraPermissions() async {
final hasPermission = await Permission.camera.isGranted;
if (!hasPermission) {
Permission.camera.request();
await Permission.camera.request();
}
}
void _onUploadFromFilesystem() async {
FilePickerResult? result = await FilePicker.platform.pickFiles(
type: FileType.custom,
allowedExtensions: _supportedExtensions,
allowedExtensions: supportedFileExtensions,
withData: true,
);
if (result?.files.single.path != null) {
File file = File(result!.files.single.path!);
if (!supportedFileExtensions.contains(file.path.split('.').last)) {
//TODO: Show error message;
return;
}
final mimeType = lookupMimeType(file.path) ?? '';
late Uint8List fileBytes;
if (mimeType.startsWith('image')) {
@@ -0,0 +1,21 @@
import 'package:flutter/material.dart';
import 'package:paperless_mobile/features/settings/view/widgets/clear_storage_setting.dart';
import 'package:paperless_mobile/generated/l10n.dart';
class StorageSettingsPage extends StatelessWidget {
const StorageSettingsPage({super.key});
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: Text(S.of(context).settingsPageStorageSettingsLabel),
),
body: ListView(
children: const [
ClearStorageSetting(),
],
),
);
}
}
+11 -9
View File
@@ -3,16 +3,12 @@ import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:paperless_mobile/features/settings/bloc/application_settings_cubit.dart';
import 'package:paperless_mobile/features/settings/view/pages/application_settings_page.dart';
import 'package:paperless_mobile/features/settings/view/pages/security_settings_page.dart';
import 'package:paperless_mobile/features/settings/view/pages/storage_settings_page.dart';
import 'package:paperless_mobile/generated/l10n.dart';
class SettingsPage extends StatefulWidget {
class SettingsPage extends StatelessWidget {
const SettingsPage({super.key});
@override
State<SettingsPage> createState() => _SettingsPageState();
}
class _SettingsPageState extends State<SettingsPage> {
@override
Widget build(BuildContext context) {
return Scaffold(
@@ -25,20 +21,26 @@ class _SettingsPageState extends State<SettingsPage> {
title: Text(S.of(context).settingsPageApplicationSettingsLabel),
subtitle: Text(
S.of(context).settingsPageApplicationSettingsDescriptionText),
onTap: () => _goto(const ApplicationSettingsPage()),
onTap: () => _goto(const ApplicationSettingsPage(), context),
),
ListTile(
title: Text(S.of(context).settingsPageSecuritySettingsLabel),
subtitle:
Text(S.of(context).settingsPageSecuritySettingsDescriptionText),
onTap: () => _goto(const SecuritySettingsPage()),
onTap: () => _goto(const SecuritySettingsPage(), context),
),
ListTile(
title: Text(S.of(context).settingsPageStorageSettingsLabel),
subtitle:
Text(S.of(context).settingsPageStorageSettingsDescriptionText),
onTap: () => _goto(const StorageSettingsPage(), context),
),
],
),
);
}
void _goto(Widget page) {
void _goto(Widget page, BuildContext context) {
Navigator.push(
context,
MaterialPageRoute(
@@ -0,0 +1,23 @@
import 'package:flutter/material.dart';
import 'package:flutter_cache_manager/flutter_cache_manager.dart' as cm;
import 'package:paperless_mobile/core/service/file_service.dart';
import 'package:paperless_mobile/di_initializer.dart';
class ClearStorageSetting extends StatelessWidget {
const ClearStorageSetting({super.key});
@override
Widget build(BuildContext context) {
return ListTile(
title: Text("Clear data"),
subtitle:
Text("Remove downloaded files, scans and clear the cache's content"),
onTap: _clearCache,
);
}
void _clearCache() async {
getIt<cm.CacheManager>().emptyCache();
FileService.clearUserData();
}
}
+6 -3
View File
@@ -102,7 +102,7 @@
"documentScannerPageTitle": "Scanner",
"documentScannerPageResetButtonTooltipText": "Alle scans löschen",
"documentScannerPageUploadButtonTooltip": "Dokument hochladen",
"documentScannerPageAddScanButtonLabel": "Scanne ein Dokument",
"documentScannerPageAddScanButtonLabel": "Scanne ein Dokument",
"documentScannerPageOrText": "oder",
"documentScannerPageUploadFromThisDeviceButtonLabel": "Lade ein Dokument von diesem Gerät hoch",
"addTagPageTitle": "Neuer Tag",
@@ -185,6 +185,9 @@
"labelsPageStoragePathEmptyStateDescriptionText": "Es wurden noch keine Speicherpfade angelegt.",
"referencedDocumentsReadOnlyHintText": "Dies ist eine schreibgeschützte Ansicht! Dokumente können nicht bearbeitet oder entfernt werden.",
"editLabelPageConfirmDeletionDialogTitle": "Löschen bestätigen",
"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?",
"settingsPageStorageSettingsLabel": "Storage",
"settingsPageStorageSettingsDescriptionText": "Manage files and storage space",
"documentUpdateErrorMessage": "Document successfully updated.",
"errorMessageMissingClientCertificate": "Ein Client Zerfitikat wurde erwartet, aber nicht gesendet. Bitte konfiguriere ein gültiges Zertifikat."
}
+5 -1
View File
@@ -186,5 +186,9 @@
"labelsPageStoragePathEmptyStateDescriptionText": "You don't seem to have any storage paths set up.",
"referencedDocumentsReadOnlyHintText": "This is a read-only view! You cannot edit or remove documents.",
"editLabelPageConfirmDeletionDialogTitle": "Confirm deletion",
"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?",
"settingsPageStorageSettingsLabel": "Storage",
"settingsPageStorageSettingsDescriptionText": "Manage files and storage space",
"documentUpdateErrorMessage": "Document successfully updated.",
"errorMessageMissingClientCertificate": "A client certificate was expected but not sent. Please provide a valid client certificate."
}
+99 -36
View File
@@ -1,19 +1,29 @@
import 'dart:developer';
import 'dart:io';
import 'package:encrypted_shared_preferences/encrypted_shared_preferences.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:flutter_localizations/flutter_localizations.dart';
import 'package:flutter_native_splash/flutter_native_splash.dart';
import 'package:paperless_mobile/core/bloc/connectivity_cubit.dart';
import 'package:paperless_mobile/core/bloc/global_error_cubit.dart';
import 'package:paperless_mobile/core/bloc/label_bloc_provider.dart';
import 'package:paperless_mobile/core/global/asset_images.dart';
import 'package:paperless_mobile/core/global/constants.dart';
import 'package:paperless_mobile/core/global/http_self_signed_certificate_override.dart';
import 'package:paperless_mobile/core/logic/error_code_localization_mapper.dart';
import 'package:paperless_mobile/core/model/error_message.dart';
import 'package:paperless_mobile/core/service/file_service.dart';
import 'package:paperless_mobile/core/util.dart';
import 'package:paperless_mobile/di_initializer.dart';
import 'package:paperless_mobile/features/app_intro/application_intro_slideshow.dart';
import 'package:paperless_mobile/features/documents/bloc/documents_cubit.dart';
import 'package:paperless_mobile/features/home/view/home_page.dart';
import 'package:paperless_mobile/features/login/bloc/authentication_cubit.dart';
import 'package:paperless_mobile/features/login/view/login_page.dart';
import 'package:paperless_mobile/features/scan/view/document_upload_page.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/generated/l10n.dart';
@@ -23,6 +33,7 @@ import 'package:intl/intl.dart';
import 'package:intl/intl_standalone.dart';
import 'package:package_info_plus/package_info_plus.dart';
import 'package:path_provider/path_provider.dart';
import 'package:receive_sharing_intent/receive_sharing_intent.dart';
void main() async {
final widgetsBinding = WidgetsFlutterBinding.ensureInitialized();
@@ -34,8 +45,10 @@ void main() async {
configureDependencies();
// Remove temporarily downloaded files.
(await getTemporaryDirectory()).deleteSync(recursive: true);
(await FileService.temporaryDirectory).deleteSync(recursive: true);
if (kDebugMode) {
_printDeviceInformation();
}
kPackageInfo = await PackageInfo.fromPlatform();
// Load application settings and stored authentication data
getIt<ConnectivityCubit>().initialize();
@@ -45,6 +58,17 @@ void main() async {
runApp(const MyApp());
}
void _printDeviceInformation() async {
final tempPath = await FileService.temporaryDirectory;
log('[DEVICE INFO] Temporary ${tempPath.absolute}');
final docsPath = await FileService.documentsDirectory;
log('[DEVICE INFO] Documents ${docsPath?.absolute}');
final downloadPath = await FileService.downloadsDirectory;
log('[DEVICE INFO] Download ${downloadPath?.absolute}');
final scanPath = await FileService.scanDirectory;
log('[DEVICE INFO] Scan ${scanPath?.absolute}');
}
class MyApp extends StatefulWidget {
const MyApp({Key? key}) : super(key: key);
@@ -125,6 +149,48 @@ class AuthenticationWrapper extends StatefulWidget {
}
class _AuthenticationWrapperState extends State<AuthenticationWrapper> {
bool isFileTypeSupported(SharedMediaFile file) {
return supportedFileExtensions.contains(file.path.split('.').last);
}
void handleReceivedFiles(List<SharedMediaFile> files) async {
if (files.isEmpty) {
return;
}
if (!isFileTypeSupported(files.first)) {
showError(context, const ErrorMessage(ErrorCode.unsupportedFileFormat));
await Future.delayed(
const Duration(seconds: 2),
() => SystemNavigator.pop(),
);
}
final bytes = File(files.first.path).readAsBytesSync();
Navigator.push(
context,
MaterialPageRoute(
builder: (context) => BlocProvider.value(
value: getIt<DocumentsCubit>(),
child: LabelBlocProvider(
child: DocumentUploadPage(
fileBytes: bytes,
afterUpload: () => SystemNavigator.pop(),
),
),
),
),
);
}
@override
void initState() {
super.initState();
// For sharing files coming from outside the app while the app is still opened
ReceiveSharingIntent.getMediaStream().listen(handleReceivedFiles);
// For sharing files coming from outside the app while the app is closed
ReceiveSharingIntent.getInitialMedia().then(handleReceivedFiles);
}
@override
void didChangeDependencies() {
FlutterNativeSplash.remove();
@@ -136,40 +202,37 @@ class _AuthenticationWrapperState extends State<AuthenticationWrapper> {
@override
Widget build(BuildContext context) {
return BlocProvider.value(
value: getIt<GlobalErrorCubit>(),
child: SafeArea(
top: true,
left: false,
right: false,
bottom: false,
child: BlocConsumer<AuthenticationCubit, AuthenticationState>(
listener: (context, authState) {
final bool showIntroSlider =
authState.isAuthenticated && !authState.wasLoginStored;
if (showIntroSlider) {
for (final img in AssetImages.values) {
img.load(context);
}
Navigator.push(
context,
MaterialPageRoute(
builder: (context) => const ApplicationIntroSlideshow(),
fullscreenDialog: true,
),
);
return SafeArea(
top: true,
left: false,
right: false,
bottom: false,
child: BlocConsumer<AuthenticationCubit, AuthenticationState>(
listener: (context, authState) {
final bool showIntroSlider =
authState.isAuthenticated && !authState.wasLoginStored;
if (showIntroSlider) {
for (final img in AssetImages.values) {
img.load(context);
}
},
builder: (context, authentication) {
if (authentication.isAuthenticated) {
return const LabelBlocProvider(
child: HomePage(),
);
} else {
return const LoginPage();
}
},
),
Navigator.push(
context,
MaterialPageRoute(
builder: (context) => const ApplicationIntroSlideshow(),
fullscreenDialog: true,
),
);
}
},
builder: (context, authentication) {
if (authentication.isAuthenticated) {
return const LabelBlocProvider(
child: HomePage(),
);
} else {
return const LoginPage();
}
},
),
);
}
+5 -3
View File
@@ -13,12 +13,14 @@ final dateFormat = DateFormat("yyyy-MM-dd");
final GlobalKey<ScaffoldState> rootScaffoldKey = GlobalKey<ScaffoldState>();
late PackageInfo kPackageInfo;
void showSnackBar(BuildContext context, String message) {
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text(message)));
void showSnackBar(BuildContext context, String message, [String? details]) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(message + (details != null ? ' ($details)' : ''))),
);
}
void showError(BuildContext context, ErrorMessage error) {
showSnackBar(context, translateError(context, error.code));
showSnackBar(context, translateError(context, error.code), error.details);
}
bool isNotNull(dynamic value) {
+14 -5
View File
@@ -354,10 +354,12 @@ packages:
edge_detection:
dependency: "direct main"
description:
name: edge_detection
url: "https://pub.dartlang.org"
source: hosted
version: "1.0.9"
path: "."
ref: master
resolved-ref: "19fbebef99360e9cf0b59c6a90ff7cd26d4d6e7d"
url: "https://github.com/sawankumarbundelkhandi/edge_detection"
source: git
version: "1.1.1"
encrypt:
dependency: transitive
description:
@@ -1123,6 +1125,13 @@ packages:
url: "https://pub.dartlang.org"
source: hosted
version: "3.0.1"
receive_sharing_intent:
dependency: "direct main"
description:
name: receive_sharing_intent
url: "https://pub.dartlang.org"
source: hosted
version: "1.4.5"
rxdart:
dependency: transitive
description:
@@ -1458,7 +1467,7 @@ packages:
source: hosted
version: "3.0.1"
uuid:
dependency: transitive
dependency: "direct main"
description:
name: uuid
url: "https://pub.dartlang.org"
+6 -1
View File
@@ -39,7 +39,10 @@ dependencies:
permission_handler: ^9.2.0
pdf: ^3.8.1
pdfx: ^2.3.0
edge_detection: ^1.0.9
edge_detection:
git:
url: https://github.com/sawankumarbundelkhandi/edge_detection
ref: master
path_provider: ^2.0.10
image: ^3.1.3
photo_view: ^0.14.0
@@ -74,6 +77,8 @@ dependencies:
share_plus: ^6.2.0
introduction_screen: ^3.0.2
mime: ^1.0.2
receive_sharing_intent: ^1.4.5
uuid: ^3.0.6
dev_dependencies:
integration_test:
File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 4.9 KiB

+9 -10
View File
@@ -1,5 +1,4 @@
import 'package:bloc_test/bloc_test.dart';
import 'package:paperless_mobile/core/bloc/global_error_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/model/document.model.dart';
@@ -15,32 +14,32 @@ import 'package:mockito/mockito.dart';
import '../../utils.dart';
@GenerateNiceMocks([MockSpec<DocumentRepository>()])
@GenerateNiceMocks([MockSpec<GlobalErrorCubit>()])
import 'document_cubit_test.mocks.dart';
void main() async {
TestWidgetsFlutterBinding.ensureInitialized();
final List<DocumentModel> documents = List.unmodifiable(
await loadCollection("test/fixtures/documents/documents.json", DocumentModel.fromJson),
await loadCollection(
"test/fixtures/documents/documents.json", DocumentModel.fromJson),
);
final List<Tag> tags = List.unmodifiable(
await loadCollection("test/fixtures/tags/tags.json", Tag.fromJson),
);
final List<Correspondent> correspondents = List.unmodifiable(
await loadCollection(
"test/fixtures/correspondents/correspondents.json", Correspondent.fromJson),
await loadCollection("test/fixtures/correspondents/correspondents.json",
Correspondent.fromJson),
);
final List<DocumentType> documentTypes = List.unmodifiable(
await loadCollection("test/fixtures/document_types/document_types.json", DocumentType.fromJson),
await loadCollection("test/fixtures/document_types/document_types.json",
DocumentType.fromJson),
);
final MockDocumentRepository documentRepository = MockDocumentRepository();
final MockGlobalErrorCubit globalErrorCubit = MockGlobalErrorCubit();
group("Test DocumentsCubit reloadDocuments", () {
test("Assert correct initial state", () {
expect(DocumentsCubit(documentRepository, globalErrorCubit).state, DocumentsState.initial);
expect(DocumentsCubit(documentRepository).state, DocumentsState.initial);
});
blocTest<DocumentsCubit, DocumentsState>(
@@ -53,7 +52,7 @@ void main() async {
results: documents,
),
),
build: () => DocumentsCubit(documentRepository, globalErrorCubit),
build: () => DocumentsCubit(documentRepository),
seed: () => DocumentsState.initial,
act: (bloc) => bloc.loadDocuments(),
expect: () => [
@@ -82,7 +81,7 @@ void main() async {
results: documents,
),
),
build: () => DocumentsCubit(documentRepository, globalErrorCubit),
build: () => DocumentsCubit(documentRepository),
seed: () => DocumentsState.initial,
act: (bloc) => bloc.loadDocuments(),
expect: () => [