Initial commit

This commit is contained in:
Anton Stubenbord
2022-10-30 14:15:37 +01:00
commit cb797df7d2
272 changed files with 16278 additions and 0 deletions

View File

@@ -0,0 +1,29 @@
import 'dart:async';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:flutter_paperless_mobile/core/service/connectivity_status.service.dart';
import 'package:injectable/injectable.dart';
@singleton
class ConnectivityCubit extends Cubit<ConnectivityState> {
final ConnectivityStatusService connectivityStatusService;
late final StreamSubscription<bool> _sub;
ConnectivityCubit(this.connectivityStatusService) : super(ConnectivityState.undefined);
Future<void> initialize() async {
final bool isConnected = await connectivityStatusService.isConnectedToInternet();
emit(isConnected ? ConnectivityState.connected : ConnectivityState.notConnected);
_sub = connectivityStatusService.connectivityChanges().listen((isConnected) {
emit(isConnected ? ConnectivityState.connected : ConnectivityState.notConnected);
});
}
@override
Future<void> close() {
_sub.cancel();
return super.close();
}
}
enum ConnectivityState { connected, notConnected, undefined }

View File

@@ -0,0 +1,10 @@
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:flutter_paperless_mobile/core/model/document_processing_status.dart';
import 'package:injectable/injectable.dart';
@singleton
class DocumentStatusCubit extends Cubit<DocumentProcessingStatus?> {
DocumentStatusCubit() : super(null);
void updateStatus(DocumentProcessingStatus? status) => emit(status);
}

View File

@@ -0,0 +1,27 @@
import 'package:flutter/widgets.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:flutter_paperless_mobile/di_initializer.dart';
import 'package:flutter_paperless_mobile/features/documents/bloc/saved_view_cubit.dart';
import 'package:flutter_paperless_mobile/features/labels/correspondent/bloc/correspondents_cubit.dart';
import 'package:flutter_paperless_mobile/features/labels/document_type/bloc/document_type_cubit.dart';
import 'package:flutter_paperless_mobile/features/labels/storage_path/bloc/storage_path_cubit.dart';
import 'package:flutter_paperless_mobile/features/labels/tags/bloc/tags_cubit.dart';
class LabelBlocProvider extends StatelessWidget {
final Widget child;
const LabelBlocProvider({super.key, required this.child});
@override
Widget build(BuildContext context) {
return MultiBlocProvider(
providers: [
BlocProvider.value(value: getIt<DocumentTypeCubit>()),
BlocProvider.value(value: getIt<CorrespondentCubit>()),
BlocProvider.value(value: getIt<TagCubit>()),
BlocProvider.value(value: getIt<StoragePathCubit>()),
BlocProvider.value(value: getIt<SavedViewCubit>()),
],
child: child,
);
}
}

View File

@@ -0,0 +1,53 @@
import 'package:flutter/foundation.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:flutter_paperless_mobile/features/labels/model/label.model.dart';
import 'package:flutter_paperless_mobile/features/labels/repository/label_repository.dart';
abstract class LabelCubit<T extends Label> extends Cubit<Map<int, T>> {
final LabelRepository labelRepository;
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) async {
assert(item.id == null);
final addedItem = await save(item);
final newState = {...state};
newState.putIfAbsent(addedItem.id!, () => addedItem);
emit(newState);
return addedItem;
}
Future<T> replace(T item) async {
assert(item.id != null);
final updatedItem = await update(item);
final newState = {...state};
newState[item.id!] = updatedItem;
emit(newState);
return updatedItem;
}
Future<void> remove(T item) async {
assert(item.id != null);
if (state.containsKey(item.id)) {
final deletedId = await delete(item);
final newState = {...state};
newState.remove(deletedId);
emit(newState);
}
}
void reset() => emit({});
Future<void> initialize();
@protected
Future<T> save(T item);
@protected
Future<T> update(T item);
@protected
Future<int> delete(T item);
}

View File

@@ -0,0 +1,11 @@
// Fix for accepting self signed certificates.
import 'dart:io';
class X509HttpOverrides extends HttpOverrides {
@override
HttpClient createHttpClient(SecurityContext? context) {
return super.createHttpClient(context)
..badCertificateCallback =
(X509Certificate cert, String host, int port) => true;
}
}

View File

@@ -0,0 +1,34 @@
import 'dart:developer';
import 'package:flutter/foundation.dart';
import 'package:flutter_paperless_mobile/core/model/error_message.dart';
import 'package:flutter_paperless_mobile/features/login/bloc/authentication_cubit.dart';
import 'package:http_interceptor/http_interceptor.dart';
import 'package:injectable/injectable.dart';
@injectable
class AuthenticationInterceptor implements InterceptorContract {
AuthenticationCubit authenticationCubit;
AuthenticationInterceptor(this.authenticationCubit);
@override
Future<BaseRequest> interceptRequest({required BaseRequest request}) async {
final authState = authenticationCubit.state;
if (kDebugMode) {
log("Intercepted request to ${request.url.toString()}");
}
if (authState.authentication == null) {
throw const ErrorMessage(ErrorCode.notAuthenticated);
}
return request.copyWith(
//Append server Url
url: Uri.parse(authState.authentication!.serverUrl + request.url.toString()),
headers: authState.authentication!.token.isEmpty
? request.headers
: {...request.headers, 'Authorization': 'Token ${authState.authentication!.token}'},
);
}
@override
Future<BaseResponse> interceptResponse({required BaseResponse response}) async => response;
}

View File

@@ -0,0 +1,31 @@
import 'package:flutter_paperless_mobile/core/model/error_message.dart';
import 'package:flutter_paperless_mobile/core/service/connectivity_status.service.dart';
import 'package:flutter_paperless_mobile/features/login/bloc/authentication_cubit.dart';
import 'package:http_interceptor/http_interceptor.dart';
import 'package:injectable/injectable.dart';
@injectable
class ConnectionStateInterceptor implements InterceptorContract {
final AuthenticationCubit authenticationCubit;
final ConnectivityStatusService connectivityStatusService;
ConnectionStateInterceptor(
this.authenticationCubit, this.connectivityStatusService);
@override
Future<BaseRequest> interceptRequest({required BaseRequest request}) async {
if (!(await connectivityStatusService.isConnectedToInternet())) {
throw const ErrorMessage(ErrorCode.deviceOffline);
}
final isServerReachable =
await connectivityStatusService.isServerReachable(request.url.origin);
if (!isServerReachable) {
throw const ErrorMessage(ErrorCode.serverUnreachable);
}
return request;
}
@override
Future<BaseResponse> interceptResponse(
{required BaseResponse response}) async =>
response;
}

View File

@@ -0,0 +1,25 @@
import 'package:flutter_paperless_mobile/features/settings/bloc/application_settings_cubit.dart';
import 'package:http_interceptor/http_interceptor.dart';
import 'package:injectable/injectable.dart';
@injectable
class LanguageHeaderInterceptor implements InterceptorContract {
final ApplicationSettingsCubit appSettingsCubit;
LanguageHeaderInterceptor(this.appSettingsCubit);
@override
Future<BaseRequest> interceptRequest({required BaseRequest request}) async {
late String languages;
if (appSettingsCubit.state.preferredLocaleSubtag == "en") {
languages = "en";
} else {
languages = appSettingsCubit.state.preferredLocaleSubtag + ",en;q=0.7,en-US;q=0.6";
}
request.headers.addAll({"Accept-Language": languages});
return request;
}
@override
Future<BaseResponse> interceptResponse({required BaseResponse response}) async => response;
}

View File

@@ -0,0 +1,32 @@
import 'package:http/http.dart';
import 'package:http_interceptor/http/http.dart';
import 'package:injectable/injectable.dart';
const interceptedRoutes = ['thumb/'];
@injectable
class ResponseConversionInterceptor implements InterceptorContract {
@override
Future<BaseRequest> interceptRequest({required BaseRequest request}) async => request;
@override
Future<BaseResponse> interceptResponse({required BaseResponse response}) async {
final String requestUrl = response.request?.url.toString().split("?").first ?? '';
if (response.request?.method == "GET" &&
interceptedRoutes.any((element) => requestUrl.endsWith(element))) {
final resp = response as Response;
return StreamedResponse(
Stream.value(resp.bodyBytes.toList()).asBroadcastStream(),
resp.statusCode,
contentLength: resp.contentLength,
headers: resp.headers,
isRedirect: resp.isRedirect,
persistentConnection: false,
reasonPhrase: resp.reasonPhrase,
request: resp.request,
);
}
return response;
}
}

View File

@@ -0,0 +1,70 @@
import 'package:flutter/cupertino.dart';
import 'package:flutter_paperless_mobile/core/model/error_message.dart';
import 'package:flutter_paperless_mobile/generated/l10n.dart';
String translateError(BuildContext context, ErrorCode code) {
switch (code) {
case ErrorCode.unknown:
return S.of(context).errorMessageUnknonwnError;
case ErrorCode.authenticationFailed:
return S.of(context).errorMessageAuthenticationFailed;
case ErrorCode.notAuthenticated:
return S.of(context).errorMessageNotAuthenticated;
case ErrorCode.documentUploadFailed:
return S.of(context).errorMessageDocumentUploadFailed;
case ErrorCode.documentUpdateFailed:
return S.of(context).errorMessageDocumentUpdateFailed;
case ErrorCode.documentLoadFailed:
return S.of(context).errorMessageDocumentLoadFailed;
case ErrorCode.documentDeleteFailed:
return S.of(context).errorMessageDocumentDeleteFailed;
case ErrorCode.documentPreviewFailed:
return S.of(context).errorMessageDocumentPreviewFailed;
case ErrorCode.documentAsnQueryFailed:
return S.of(context).errorMessageDocumentAsnQueryFailed;
case ErrorCode.tagCreateFailed:
return S.of(context).errorMessageTagCreateFailed;
case ErrorCode.tagLoadFailed:
return S.of(context).errorMessageTagLoadFailed;
case ErrorCode.documentTypeCreateFailed:
return S.of(context).errorMessageDocumentTypeCreateFailed;
case ErrorCode.documentTypeLoadFailed:
return S.of(context).errorMessageDocumentTypeLoadFailed;
case ErrorCode.correspondentCreateFailed:
return S.of(context).errorMessageCorrespondentCreateFailed;
case ErrorCode.correspondentLoadFailed:
return S.of(context).errorMessageCorrespondentLoadFailed;
case ErrorCode.scanRemoveFailed:
return S.of(context).errorMessageScanRemoveFailed;
case ErrorCode.invalidClientCertificateConfiguration:
return S.of(context).errorMessageInvalidClientCertificateConfiguration;
case ErrorCode.documentBulkDeleteFailed:
return S.of(context).errorMessageBulkDeleteDocumentsFailed;
case ErrorCode.biometricsNotSupported:
return S.of(context).errorMessageBiotmetricsNotSupported;
case ErrorCode.biometricAuthenticationFailed:
return S.of(context).errorMessageBiometricAuthenticationFailed;
case ErrorCode.deviceOffline:
return S.of(context).errorMessageDeviceOffline;
case ErrorCode.serverUnreachable:
return S.of(context).errorMessageServerUnreachable;
case ErrorCode.similarQueryError:
return S.of(context).errorMessageSimilarQueryError;
case ErrorCode.autocompleteQueryError:
return S.of(context).errorMessageAutocompleteQueryError;
case ErrorCode.storagePathLoadFailed:
return S.of(context).errorMessageStoragePathLoadFailed;
case ErrorCode.storagePathCreateFailed:
return S.of(context).errorMessageStoragePathCreateFailed;
case ErrorCode.loadSavedViewsError:
return S.of(context).errorMessageLoadSavedViewsError;
case ErrorCode.createSavedViewError:
return S.of(context).errorMessageCreateSavedViewError;
case ErrorCode.deleteSavedViewError:
return S.of(context).errorMessageDeleteSavedViewError;
case ErrorCode.requestTimedOut:
return S.of(context).errorMessageRequestTimedOut;
default:
return S.of(context).errorMessageUnknonwnError;
}
}

View File

@@ -0,0 +1,135 @@
import 'dart:typed_data';
import 'dart:convert';
import 'package:flutter_paperless_mobile/core/model/error_message.dart';
import 'package:flutter_paperless_mobile/core/type/json.dart';
import 'package:flutter_paperless_mobile/di_initializer.dart';
import 'package:http/http.dart';
import 'package:injectable/injectable.dart';
///
/// Convenience class which handles timeout errors.
///
@Injectable(as: BaseClient)
@Named("timeoutClient")
class TimeoutClient implements BaseClient {
static const Duration requestTimeout = Duration(seconds: 25);
@override
Future<StreamedResponse> send(BaseRequest request) async {
return getIt<BaseClient>().send(request).timeout(
requestTimeout,
onTimeout: () => Future.error(const ErrorMessage(ErrorCode.requestTimedOut)),
);
}
@override
void close() {
getIt<BaseClient>().close();
}
@override
Future<Response> delete(Uri url,
{Map<String, String>? headers, Object? body, Encoding? encoding}) async {
return _handle400Error(
await getIt<BaseClient>()
.delete(url, headers: headers, body: body, encoding: encoding)
.timeout(
requestTimeout,
onTimeout: () => Future.error(const ErrorMessage(ErrorCode.requestTimedOut)),
),
);
}
@override
Future<Response> get(Uri url, {Map<String, String>? headers}) async {
return _handle400Error(
await getIt<BaseClient>().get(url, headers: headers).timeout(
requestTimeout,
onTimeout: () => Future.error(const ErrorMessage(ErrorCode.requestTimedOut)),
),
);
}
@override
Future<Response> head(Uri url, {Map<String, String>? headers}) async {
return _handle400Error(
await getIt<BaseClient>().head(url, headers: headers).timeout(
requestTimeout,
onTimeout: () => Future.error(const ErrorMessage(ErrorCode.requestTimedOut)),
),
);
}
@override
Future<Response> patch(Uri url,
{Map<String, String>? headers, Object? body, Encoding? encoding}) async {
return _handle400Error(
await getIt<BaseClient>()
.patch(url, headers: headers, body: body, encoding: encoding)
.timeout(
requestTimeout,
onTimeout: () => Future.error(const ErrorMessage(ErrorCode.requestTimedOut)),
),
);
}
@override
Future<Response> post(Uri url,
{Map<String, String>? headers, Object? body, Encoding? encoding}) async {
return _handle400Error(
await getIt<BaseClient>().post(url, headers: headers, body: body, encoding: encoding).timeout(
requestTimeout,
onTimeout: () => Future.error(const ErrorMessage(ErrorCode.requestTimedOut)),
),
);
}
@override
Future<Response> put(Uri url,
{Map<String, String>? headers, Object? body, Encoding? encoding}) async {
return _handle400Error(
await getIt<BaseClient>().put(url, headers: headers, body: body, encoding: encoding).timeout(
requestTimeout,
onTimeout: () => Future.error(const ErrorMessage(ErrorCode.requestTimedOut)),
),
);
}
@override
Future<String> read(Uri url, {Map<String, String>? headers}) async {
return getIt<BaseClient>().read(url, headers: headers).timeout(
requestTimeout,
onTimeout: () => Future.error(const ErrorMessage(ErrorCode.requestTimedOut)),
);
}
@override
Future<Uint8List> readBytes(Uri url, {Map<String, String>? headers}) {
return getIt<BaseClient>().readBytes(url, headers: headers).timeout(
requestTimeout,
onTimeout: () => Future.error(const ErrorMessage(ErrorCode.requestTimedOut)),
);
}
Response _handle400Error(Response response) {
if (response.statusCode == 400) {
// try to parse contained error message, otherwise return response
final JSON json = jsonDecode(response.body);
final Map<String, String> errorMessages = {};
//TODO: This could be simplified, look at error message format of paperless-ngx
for (final entry in json.entries) {
if (entry.value is List) {
errorMessages.putIfAbsent(entry.key, () => (entry.value as List).cast<String>().first);
} else if (entry.value is String) {
errorMessages.putIfAbsent(entry.key, () => entry.value);
} else {
errorMessages.putIfAbsent(entry.key, () => entry.value.toString());
}
}
throw errorMessages;
}
return response;
}
}

View File

@@ -0,0 +1,46 @@
enum ProcessingStatus { starting, working, success, error }
enum ProcessingMessage {
new_file,
parsing_document,
generating_thumbnail,
parse_date,
save_document,
finished
}
class DocumentProcessingStatus {
final int currentProgress;
final int? documentId;
final String filename;
final int maxProgress;
final ProcessingMessage message;
final ProcessingStatus status;
final String taskId;
final bool isApproximated;
static const String UNKNOWN_TASK_ID = "NO_TASK_ID";
DocumentProcessingStatus({
required this.currentProgress,
this.documentId,
required this.filename,
required this.maxProgress,
required this.message,
required this.status,
required this.taskId,
this.isApproximated = false,
});
factory DocumentProcessingStatus.fromJson(Map<dynamic, dynamic> json) {
return DocumentProcessingStatus(
currentProgress: json['current_progress'],
documentId: json['documentId'],
filename: json['filename'],
maxProgress: json['max_progress'],
message: ProcessingMessage.values.byName(json['message']),
status: ProcessingStatus.values.byName(json['status']),
taskId: json['task_id'],
);
}
}

View File

@@ -0,0 +1,50 @@
class ErrorMessage implements Exception {
final ErrorCode code;
final StackTrace? stackTrace;
final int? httpStatusCode;
const ErrorMessage(this.code, {this.stackTrace, this.httpStatusCode});
factory ErrorMessage.unknown() {
return const ErrorMessage(ErrorCode.unknown);
}
@override
String toString() {
return "ErrorMessage(code: $code${stackTrace != null ? ', stackTrace: ${stackTrace.toString()}' : ''}${httpStatusCode != null ? ', httpStatusCode: $httpStatusCode' : ''})";
}
}
enum ErrorCode {
unknown,
authenticationFailed,
notAuthenticated,
documentUploadFailed,
documentUpdateFailed,
documentLoadFailed,
documentDeleteFailed,
documentBulkDeleteFailed,
documentPreviewFailed,
documentAsnQueryFailed,
tagCreateFailed,
tagLoadFailed,
documentTypeCreateFailed,
documentTypeLoadFailed,
correspondentCreateFailed,
correspondentLoadFailed,
scanRemoveFailed,
invalidClientCertificateConfiguration,
biometricsNotSupported,
biometricAuthenticationFailed,
deviceOffline,
serverUnreachable,
similarQueryError,
autocompleteQueryError,
storagePathLoadFailed,
storagePathCreateFailed,
loadSavedViewsError,
createSavedViewError,
deleteSavedViewError,
requestTimedOut,
storagePathAlreadyExists;
}

View File

@@ -0,0 +1,51 @@
import 'dart:io';
import 'package:connectivity_plus/connectivity_plus.dart';
import 'package:injectable/injectable.dart';
abstract class ConnectivityStatusService {
Future<bool> isConnectedToInternet();
Future<bool> isServerReachable(String serverAddress);
Stream<bool> connectivityChanges();
}
@Injectable(as: ConnectivityStatusService)
class ConnectivityStatusServiceImpl implements ConnectivityStatusService {
final Connectivity connectivity;
ConnectivityStatusServiceImpl(this.connectivity);
@override
Stream<bool> connectivityChanges() {
return connectivity.onConnectivityChanged
.map(_hasActiveInternetConnection)
.asBroadcastStream();
}
@override
Future<bool> isConnectedToInternet() async {
return _hasActiveInternetConnection(
await (Connectivity().checkConnectivity()));
}
@override
Future<bool> isServerReachable(String serverAddress) async {
try {
final result = await InternetAddress.lookup(
serverAddress.replaceAll(RegExp(r"https?://"), ""));
if (result.isNotEmpty && result.first.rawAddress.isNotEmpty) {
return true;
} else {
return false;
}
} on SocketException catch (_) {
return false;
}
}
bool _hasActiveInternetConnection(ConnectivityResult conn) {
return conn == ConnectivityResult.mobile ||
conn == ConnectivityResult.wifi ||
conn == ConnectivityResult.ethernet;
}
}

View File

@@ -0,0 +1,112 @@
import 'dart:convert';
import 'dart:io';
import 'package:flutter_paperless_mobile/core/bloc/document_status_cubit.dart';
import 'package:flutter_paperless_mobile/core/model/document_processing_status.dart';
import 'package:flutter_paperless_mobile/di_initializer.dart';
import 'package:flutter_paperless_mobile/features/documents/model/document.model.dart';
import 'package:flutter_paperless_mobile/features/documents/model/paged_search_result.dart';
import 'package:flutter_paperless_mobile/features/login/model/authentication_information.dart';
import 'package:flutter_paperless_mobile/util.dart';
import 'package:http_interceptor/http_interceptor.dart';
import 'package:injectable/injectable.dart';
import 'package:web_socket_channel/io.dart';
abstract class StatusService {
Future<void> startListeningBeforeDocumentUpload(
String httpUrl, AuthenticationInformation credentials, String documentFileName);
}
@Singleton(as: StatusService)
@Named("webSocketStatusService")
class WebSocketStatusService implements StatusService {
late WebSocket? socket;
late IOWebSocketChannel? _channel;
WebSocketStatusService();
@override
Future<void> startListeningBeforeDocumentUpload(
String httpUrl,
AuthenticationInformation credentials,
String documentFileName,
) async {
socket = await WebSocket.connect(
httpUrl.replaceFirst("http", "ws") + "/ws/status/",
customClient: getIt<HttpClient>(),
headers: {
'Authorization': 'Token ${credentials.token}',
},
).catchError((_) {
// Use long polling if connection could not be established
});
if (socket != null) {
socket!.where(isNotNull).listen((event) {
final status = DocumentProcessingStatus.fromJson(event);
getIt<DocumentStatusCubit>().updateStatus(status);
if (status.currentProgress == 100) {
socket!.close();
}
});
}
}
}
@Injectable(as: StatusService)
@Named("longPollingStatusService")
class LongPollingStatusService implements StatusService {
static const maxRetries = 60;
final BaseClient httpClient;
LongPollingStatusService(@Named("timeoutClient") this.httpClient);
@override
Future<void> startListeningBeforeDocumentUpload(
String httpUrl,
AuthenticationInformation credentials,
String documentFileName,
) async {
final today = DateTime.now();
bool consumptionFinished = false;
int retryCount = 0;
getIt<DocumentStatusCubit>().updateStatus(
DocumentProcessingStatus(
currentProgress: 0,
filename: documentFileName,
maxProgress: 100,
message: ProcessingMessage.new_file,
status: ProcessingStatus.working,
taskId: DocumentProcessingStatus.UNKNOWN_TASK_ID,
documentId: null,
isApproximated: true,
),
);
do {
final response = await httpClient.get(
Uri.parse('$httpUrl/api/documents/?query=$documentFileName added:${formatDate(today)}'),
);
final data = PagedSearchResult.fromJson(jsonDecode(response.body), DocumentModel.fromJson);
if (data.count > 0) {
consumptionFinished = true;
final docId = data.results[0].id;
getIt<DocumentStatusCubit>().updateStatus(
DocumentProcessingStatus(
currentProgress: 100,
filename: documentFileName,
maxProgress: 100,
message: ProcessingMessage.finished,
status: ProcessingStatus.success,
taskId: DocumentProcessingStatus.UNKNOWN_TASK_ID,
documentId: docId,
isApproximated: true,
),
);
return;
}
sleep(const Duration(seconds: 1));
} while (!consumptionFinished && retryCount < maxRetries);
}
}

View File

@@ -0,0 +1,55 @@
import 'dart:convert';
import 'package:encrypted_shared_preferences/encrypted_shared_preferences.dart';
import 'package:flutter_paperless_mobile/features/login/model/authentication_information.dart';
import 'package:flutter_paperless_mobile/features/login/model/client_certificate.dart';
import 'package:flutter_paperless_mobile/features/settings/model/application_settings_state.dart';
import 'package:injectable/injectable.dart';
@singleton
class LocalVault {
static const applicationSettingsKey = "applicationSettings";
static const authenticationKey = "authentication";
final EncryptedSharedPreferences sharedPreferences;
LocalVault(this.sharedPreferences);
Future<void> storeAuthenticationInformation(
AuthenticationInformation auth,
) async {
await sharedPreferences.setString(
authenticationKey,
json.encode(auth.toJson()),
);
}
Future<AuthenticationInformation?> loadAuthenticationInformation() async {
if ((await sharedPreferences.getString(authenticationKey)).isEmpty) {
return null;
}
return AuthenticationInformation.fromJson(
json.decode(await sharedPreferences.getString(authenticationKey)),
);
}
Future<ClientCertificate?> loadCertificate() async {
return loadAuthenticationInformation().then((value) => value?.clientCertificate);
}
Future<bool> storeApplicationSettings(ApplicationSettingsState settings) {
return sharedPreferences.setString(applicationSettingsKey, json.encode(settings.toJson()));
}
Future<ApplicationSettingsState?> loadApplicationSettings() async {
final settings = await sharedPreferences.getString(applicationSettingsKey);
if (settings.isEmpty) {
return null;
}
return ApplicationSettingsState.fromJson(json.decode(settings));
}
Future<void> clear() {
return sharedPreferences.clear();
}
}

1
lib/core/type/json.dart Normal file
View File

@@ -0,0 +1 @@
typedef JSON = Map<String, dynamic>;

66
lib/core/util.dart Normal file
View File

@@ -0,0 +1,66 @@
import 'dart:convert';
import 'dart:io';
import 'dart:typed_data';
import 'package:flutter_paperless_mobile/core/logic/timeout_client.dart';
import 'package:flutter_paperless_mobile/core/model/error_message.dart';
import 'package:flutter_paperless_mobile/core/type/json.dart';
import 'package:flutter_paperless_mobile/di_initializer.dart';
import 'package:http/http.dart';
import 'package:path_provider/path_provider.dart';
const requestTimeout = Duration(seconds: 5);
Future<T> getSingleResult<T>(
String url,
T Function(JSON) fromJson,
ErrorCode errorCode, {
int minRequiredApiVersion = 1,
}) async {
final httpClient = getIt<BaseClient>(instanceName: "timeoutClient");
final response = await httpClient.get(
Uri.parse(url),
headers: {'accept': 'application/json; version=$minRequiredApiVersion'},
);
if (response.statusCode == 200) {
return fromJson(jsonDecode(utf8.decode(response.bodyBytes)) as JSON);
}
return Future.error(errorCode);
}
Future<List<T>> getCollection<T>(
String url,
T Function(JSON) fromJson,
ErrorCode errorCode, {
int minRequiredApiVersion = 1,
}) async {
final httpClient = getIt<BaseClient>(instanceName: "timeoutClient");
final response = await httpClient.get(
Uri.parse(url),
headers: {'accept': 'application/json; version=$minRequiredApiVersion'},
);
if (response.statusCode == 200) {
final JSON body = jsonDecode(utf8.decode(response.bodyBytes));
if (body.containsKey('count')) {
if (body['count'] == 0) {
return <T>[];
} else {
return body['results'].cast<JSON>().map<T>((result) => fromJson(result)).toList();
}
}
}
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;
}
}

View File

@@ -0,0 +1,15 @@
import 'package:flutter/material.dart';
class ComingSoon extends StatelessWidget {
const ComingSoon({super.key});
@override
Widget build(BuildContext context) {
return Center(
child: Text(
"Coming Soon\u2122",
style: Theme.of(context).textTheme.titleLarge,
),
);
}
}

View File

@@ -0,0 +1,70 @@
import 'dart:math' as math;
import 'dart:ui';
import 'package:flutter/material.dart';
class ElevatedConfirmationButton extends StatefulWidget {
factory ElevatedConfirmationButton.icon(BuildContext context,
{required void Function() onPressed, required Icon icon, required Widget label}) {
final double scale = MediaQuery.maybeOf(context)?.textScaleFactor ?? 1;
final double gap = scale <= 1 ? 8 : lerpDouble(8, 4, math.min(scale - 1, 1))!;
return ElevatedConfirmationButton(
child: Row(
mainAxisSize: MainAxisSize.min,
children: <Widget>[icon, SizedBox(width: gap), Flexible(child: label)],
),
onPressed: onPressed,
);
}
const ElevatedConfirmationButton({
Key? key,
this.color,
required this.onPressed,
required this.child,
this.confirmWidget = const Text("Confirm?"),
}) : super(key: key);
final Color? color;
final void Function()? onPressed;
final Widget child;
final Widget confirmWidget;
@override
State<ElevatedConfirmationButton> createState() => _ElevatedConfirmationButtonState();
}
class _ElevatedConfirmationButtonState extends State<ElevatedConfirmationButton> {
bool _clickedOnce = false;
double? _originalWidth;
final GlobalKey _originalWidgetKey = GlobalKey();
@override
Widget build(BuildContext context) {
if (!_clickedOnce) {
return ElevatedButton(
key: _originalWidgetKey,
style: ButtonStyle(
backgroundColor: MaterialStateProperty.all(widget.color),
),
onPressed: () {
_originalWidth =
(_originalWidgetKey.currentContext?.findRenderObject() as RenderBox).size.width;
setState(() => _clickedOnce = true);
},
child: widget.child,
);
} else {
return Builder(builder: (context) {
return SizedBox(
width: _originalWidth,
child: ElevatedButton(
style: ButtonStyle(
backgroundColor: MaterialStateProperty.all(widget.color),
),
onPressed: widget.onPressed,
child: widget.confirmWidget,
),
);
});
}
}
}

View File

@@ -0,0 +1,86 @@
import 'dart:math';
import 'package:flutter/material.dart';
import 'package:shimmer/shimmer.dart';
class DocumentsListLoadingWidget extends StatelessWidget {
static const tags = [" ", " ", " "];
static const titleLengths = <double>[double.infinity, 150.0, 200.0];
static const correspondentLengths = <double>[200.0, 300.0, 150.0];
static const fontSize = 16.0;
const DocumentsListLoadingWidget({super.key});
@override
Widget build(BuildContext context) {
return SizedBox(
height: MediaQuery.of(context).size.height,
width: double.infinity,
child: Column(
mainAxisSize: MainAxisSize.max,
children: <Widget>[
Expanded(
child: Shimmer.fromColors(
baseColor: Theme.of(context).brightness == Brightness.light
? Colors.grey[300]!
: Colors.grey[900]!,
highlightColor: Theme.of(context).brightness == Brightness.light
? Colors.grey[100]!
: Colors.grey[600]!,
child: ListView.builder(
physics: const NeverScrollableScrollPhysics(),
itemBuilder: (context, index) {
final r = Random(index);
final tagCount = r.nextInt(tags.length + 1);
final correspondentLength = correspondentLengths[
r.nextInt(correspondentLengths.length - 1)];
final titleLength =
titleLengths[r.nextInt(titleLengths.length - 1)];
return ListTile(
isThreeLine: true,
leading: Container(
color: Colors.white,
height: 50,
width: 50,
),
title: Container(
padding: const EdgeInsets.symmetric(vertical: 2.0),
width: correspondentLength,
height: fontSize,
color: Colors.white,
),
subtitle: Padding(
padding: const EdgeInsets.symmetric(vertical: 2.0),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisAlignment: MainAxisAlignment.spaceAround,
children: [
Container(
padding: const EdgeInsets.symmetric(vertical: 2.0),
height: fontSize,
width: titleLength,
color: Colors.white,
),
Wrap(
spacing: 2.0,
children: List.generate(
tagCount,
(index) => Chip(
label: Text(tags[r.nextInt(tags.length)]),
),
),
),
],
),
),
);
},
itemCount: 25,
),
),
),
],
),
);
}
}

View File

@@ -0,0 +1,45 @@
import 'package:flutter/material.dart';
import 'package:flutter_svg/flutter_svg.dart';
class EmptyState extends StatelessWidget {
final String title;
final String subtitle;
final Widget? bottomChild;
const EmptyState({
Key? key,
required this.title,
required this.subtitle,
this.bottomChild,
}) : super(key: key);
@override
Widget build(BuildContext context) {
final size = MediaQuery.of(context).size;
return Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
SizedBox(
height: size.height / 3,
width: size.width / 3,
child: SvgPicture.asset("assets/images/empty-state.svg"),
),
Column(
children: [
Text(
title,
style: Theme.of(context).textTheme.titleLarge?.copyWith(
fontWeight: FontWeight.bold,
),
),
Text(
subtitle,
style: Theme.of(context).textTheme.titleMedium,
),
],
),
if (bottomChild != null) ...[bottomChild!] else ...[]
],
);
}
}

View File

@@ -0,0 +1,215 @@
import 'dart:math' as math;
import 'package:flutter/material.dart';
@immutable
class ExpandableFloatingActionButton extends StatefulWidget {
const ExpandableFloatingActionButton({
super.key,
this.initialOpen,
required this.distance,
required this.children,
});
final bool? initialOpen;
final double distance;
final List<Widget> children;
@override
State<ExpandableFloatingActionButton> createState() =>
_ExpandableFloatingActionButtonState();
}
class _ExpandableFloatingActionButtonState
extends State<ExpandableFloatingActionButton>
with SingleTickerProviderStateMixin {
late final AnimationController _controller;
late final Animation<double> _expandAnimation;
bool _open = false;
@override
void initState() {
super.initState();
_open = widget.initialOpen ?? false;
_controller = AnimationController(
value: _open ? 1.0 : 0.0,
duration: const Duration(milliseconds: 250),
vsync: this,
);
_expandAnimation = CurvedAnimation(
curve: Curves.fastOutSlowIn,
reverseCurve: Curves.easeOutQuad,
parent: _controller,
);
}
@override
void dispose() {
_controller.dispose();
super.dispose();
}
void _toggle() {
setState(() {
_open = !_open;
if (_open) {
_controller.forward();
} else {
_controller.reverse();
}
});
}
@override
Widget build(BuildContext context) {
return SizedBox.expand(
child: Stack(
alignment: Alignment.bottomRight,
clipBehavior: Clip.none,
children: [
_buildTapToCloseFab(),
..._buildExpandingActionButtons(),
_buildTapToOpenFab(),
],
),
);
}
Widget _buildTapToCloseFab() {
return SizedBox(
width: 56.0,
height: 56.0,
child: Center(
child: Material(
shape: const CircleBorder(),
clipBehavior: Clip.antiAlias,
elevation: 4.0,
child: InkWell(
onTap: _toggle,
child: Padding(
padding: const EdgeInsets.all(8.0),
child: Icon(
Icons.close,
color: Theme.of(context).primaryColor,
),
),
),
),
),
);
}
List<Widget> _buildExpandingActionButtons() {
final children = <Widget>[];
final count = widget.children.length;
final step = 90.0 / (count - 1);
for (var i = 0, angleInDegrees = 0.0;
i < count;
i++, angleInDegrees += step) {
children.add(
_ExpandingActionButton(
directionInDegrees: angleInDegrees,
maxDistance: widget.distance,
progress: _expandAnimation,
child: widget.children[i],
),
);
}
return children;
}
Widget _buildTapToOpenFab() {
return IgnorePointer(
ignoring: _open,
child: AnimatedContainer(
transformAlignment: Alignment.center,
transform: Matrix4.diagonal3Values(
_open ? 0.7 : 1.0,
_open ? 0.7 : 1.0,
1.0,
),
duration: const Duration(milliseconds: 250),
curve: const Interval(0.0, 0.5, curve: Curves.easeOut),
child: AnimatedOpacity(
opacity: _open ? 0.0 : 1.0,
curve: const Interval(0.25, 1.0, curve: Curves.easeInOut),
duration: const Duration(milliseconds: 250),
child: FloatingActionButton(
onPressed: _toggle,
child: const Icon(Icons.create),
),
),
),
);
}
}
@immutable
class _ExpandingActionButton extends StatelessWidget {
const _ExpandingActionButton({
required this.directionInDegrees,
required this.maxDistance,
required this.progress,
required this.child,
});
final double directionInDegrees;
final double maxDistance;
final Animation<double> progress;
final Widget child;
@override
Widget build(BuildContext context) {
return AnimatedBuilder(
animation: progress,
builder: (context, child) {
final offset = Offset.fromDirection(
directionInDegrees * (math.pi / 180.0),
progress.value * maxDistance,
);
return Positioned(
right: 4.0 + offset.dx,
bottom: 4.0 + offset.dy,
child: Transform.rotate(
angle: (1.0 - progress.value) * math.pi / 2,
child: child!,
),
);
},
child: FadeTransition(
opacity: progress,
child: child,
),
);
}
}
@immutable
class ExpandableActionButton extends StatelessWidget {
const ExpandableActionButton({
super.key,
this.color,
this.onPressed,
required this.icon,
});
final VoidCallback? onPressed;
final Widget icon;
final Color? color;
@override
Widget build(BuildContext context) {
return SizedBox(
height: 48,
width: 48,
child: ElevatedButton(
onPressed: onPressed,
child: icon,
style: ButtonStyle(
padding: MaterialStateProperty.all(EdgeInsets.zero),
backgroundColor: MaterialStateProperty.all(color),
),
),
);
}
}

View File

@@ -0,0 +1,125 @@
import 'dart:math';
import 'package:flutter/material.dart';
class HighlightedText extends StatelessWidget {
final String text;
final List<String> highlights;
final Color? color;
final TextStyle? style;
final bool caseSensitive;
final TextAlign textAlign;
final TextDirection? textDirection;
final TextOverflow overflow;
final double textScaleFactor;
final int? maxLines;
final StrutStyle? strutStyle;
final TextWidthBasis textWidthBasis;
final TextHeightBehavior? textHeightBehavior;
const HighlightedText({
super.key,
required this.text,
required this.highlights,
this.style,
this.color = Colors.yellowAccent,
this.caseSensitive = true,
this.textAlign = TextAlign.start,
this.textDirection = TextDirection.ltr,
this.overflow = TextOverflow.clip,
this.textScaleFactor = 1.0,
this.maxLines,
this.strutStyle,
this.textWidthBasis = TextWidthBasis.parent,
this.textHeightBehavior,
});
@override
Widget build(BuildContext context) {
if (text.isEmpty || highlights.isEmpty || highlights.contains('')) {
return SelectableText.rich(
_normalSpan(text, context),
key: key,
textAlign: textAlign,
textDirection: textDirection,
textScaleFactor: textScaleFactor,
maxLines: maxLines,
strutStyle: strutStyle,
textWidthBasis: textWidthBasis,
textHeightBehavior: textHeightBehavior,
style: TextStyle(overflow: overflow),
);
}
return SelectableText.rich(
TextSpan(children: _buildChildren(context)),
key: key,
textAlign: textAlign,
textDirection: textDirection,
textScaleFactor: textScaleFactor,
maxLines: maxLines,
strutStyle: strutStyle,
textWidthBasis: textWidthBasis,
textHeightBehavior: textHeightBehavior,
style: TextStyle(overflow: overflow),
);
}
List<TextSpan> _buildChildren(BuildContext context) {
List<TextSpan> _spans = [];
int _start = 0;
String _text = caseSensitive ? text : text.toLowerCase();
List<String> _highlights =
caseSensitive ? highlights : highlights.map((e) => e.toLowerCase()).toList();
while (true) {
Map<int, String> _highlightsMap = {}; //key (index), value (highlight).
for (final h in _highlights) {
final idx = _text.indexOf(h, _start);
if (idx >= 0) {
_highlightsMap.putIfAbsent(_text.indexOf(h, _start), () => h);
}
}
if (_highlightsMap.isNotEmpty) {
int _currentIndex = _highlightsMap.keys.reduce(min);
String _currentHighlight = text.substring(
_currentIndex,
_currentIndex + _highlightsMap[_currentIndex]!.length,
);
if (_currentIndex == _start) {
_spans.add(_highlightSpan(_currentHighlight));
_start += _currentHighlight.length;
} else {
_spans.add(_normalSpan(text.substring(_start, _currentIndex), context));
_spans.add(_highlightSpan(_currentHighlight));
_start = _currentIndex + _currentHighlight.length;
}
} else {
_spans.add(_normalSpan(text.substring(_start, text.length), context));
break;
}
}
return _spans;
}
TextSpan _highlightSpan(String value) {
return TextSpan(
text: value,
style: style?.copyWith(
backgroundColor: color,
),
);
}
TextSpan _normalSpan(String value, BuildContext context) {
return TextSpan(
text: value,
style: style ?? Theme.of(context).textTheme.bodyText2,
);
}
}

View File

@@ -0,0 +1,30 @@
import 'package:flutter/material.dart';
import 'package:flutter_paperless_mobile/generated/l10n.dart';
class OfflineBanner extends StatelessWidget with PreferredSizeWidget {
const OfflineBanner({super.key});
@override
Widget build(BuildContext context) {
return Container(
color: Theme.of(context).disabledColor,
child: Row(
mainAxisAlignment: MainAxisAlignment.center,
crossAxisAlignment: CrossAxisAlignment.center,
children: [
const Padding(
padding: EdgeInsets.symmetric(horizontal: 8.0),
child: Icon(
Icons.cloud_off,
size: 24,
),
),
Text(S.of(context).genericMessageOfflineText),
],
),
);
}
@override
Size get preferredSize => const Size.fromHeight(24);
}

View File

@@ -0,0 +1,23 @@
import 'package:flutter/material.dart';
import 'package:flutter_paperless_mobile/generated/l10n.dart';
class OfflineWidget extends StatelessWidget {
const OfflineWidget({super.key});
@override
Widget build(BuildContext context) {
return Padding(
padding: const EdgeInsets.all(16.0),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(Icons.mood_bad, size: (Theme.of(context).iconTheme.size ?? 24) * 3),
Text(
S.of(context).offlineWidgetText,
textAlign: TextAlign.center,
),
],
),
);
}
}

View File

@@ -0,0 +1,23 @@
import 'package:flutter/material.dart';
import 'package:flutter_svg/flutter_svg.dart';
class PaperlessLogo extends StatelessWidget {
final double? height;
final double? width;
const PaperlessLogo({Key? key, this.height, this.width}) : super(key: key);
@override
Widget build(BuildContext context) {
return Container(
constraints: BoxConstraints(
maxHeight: height ?? Theme.of(context).iconTheme.size ?? 32,
maxWidth: width ?? Theme.of(context).iconTheme.size ?? 32,
),
padding: const EdgeInsets.only(right: 8),
child: SvgPicture.asset(
"assets/logo/paperless_ng_logo_light.svg",
color: Theme.of(context).primaryColor,
),
);
}
}

31
lib/di_initializer.dart Normal file
View File

@@ -0,0 +1,31 @@
import 'dart:io';
import 'package:flutter_paperless_mobile/di_initializer.config.dart';
import 'package:flutter_paperless_mobile/di_modules.dart';
import 'package:flutter_paperless_mobile/features/login/model/client_certificate.dart';
import 'package:get_it/get_it.dart';
import 'package:injectable/injectable.dart';
final getIt = GetIt.instance..allowReassignment;
@InjectableInit(
initializerName: r'$initGetIt', // default
preferRelativeImports: true, // default
asExtension: false, // default
)
void configureDependencies() => $initGetIt(getIt);
///
/// Registers new security context, which will be used by the HttpClient, see [RegisterModule].
///
void registerSecurityContext(ClientCertificate? cert) {
var context = SecurityContext();
if (cert != null) {
context = context
..usePrivateKeyBytes(cert.bytes, password: cert.passphrase)
..useCertificateChainBytes(cert.bytes, password: cert.passphrase)
..setTrustedCertificatesBytes(cert.bytes, password: cert.passphrase);
}
getIt.unregister<SecurityContext>();
getIt.registerSingleton<SecurityContext>(context);
}

55
lib/di_modules.dart Normal file
View File

@@ -0,0 +1,55 @@
import 'dart:io';
import 'package:connectivity_plus/connectivity_plus.dart';
import 'package:encrypted_shared_preferences/encrypted_shared_preferences.dart';
import 'package:flutter_cache_manager/flutter_cache_manager.dart';
import 'package:flutter_paperless_mobile/core/interceptor/authentication.interceptor.dart';
import 'package:flutter_paperless_mobile/core/interceptor/connection_state.interceptor.dart';
import 'package:flutter_paperless_mobile/core/interceptor/language_header.interceptor.dart';
import 'package:flutter_paperless_mobile/core/interceptor/response_conversion.interceptor.dart';
import 'package:http/http.dart';
import 'package:http/io_client.dart';
import 'package:http_interceptor/http/http.dart';
import 'package:injectable/injectable.dart';
import 'package:local_auth/local_auth.dart';
@module
abstract class RegisterModule {
@singleton
LocalAuthentication get localAuthentication => LocalAuthentication();
@singleton
EncryptedSharedPreferences get encryptedSharedPreferences => EncryptedSharedPreferences();
@singleton
SecurityContext get securityContext => SecurityContext();
@singleton
Connectivity get connectivity => Connectivity();
///
/// Factory method creating an [HttpClient] with the currently registered [SecurityContext].
///
HttpClient getHttpClient(SecurityContext securityContext) =>
HttpClient(context: securityContext)..connectionTimeout = const Duration(seconds: 10);
///
/// Factory method creating a [InterceptedClient] on top of the currently registered [HttpClient].
///
BaseClient getBaseClient(
AuthenticationInterceptor authInterceptor,
ResponseConversionInterceptor responseConversionInterceptor,
ConnectionStateInterceptor connectionStateInterceptor,
LanguageHeaderInterceptor languageHeaderInterceptor,
HttpClient client,
) =>
InterceptedClient.build(
interceptors: [
authInterceptor,
responseConversionInterceptor,
connectionStateInterceptor,
languageHeaderInterceptor
],
client: IOClient(client),
);
CacheManager getCacheManager(BaseClient client) =>
CacheManager(Config('cacheKey', fileService: HttpFileService(httpClient: client)));
}

View File

@@ -0,0 +1,9 @@
extension NullableMapKey<K, V> on Map<K, V> {
V? tryPutIfAbsent(K key, V? Function() ifAbsent) {
final value = ifAbsent();
if (value == null) {
return null;
}
return putIfAbsent(key, () => value);
}
}

View File

@@ -0,0 +1,19 @@
import 'package:flutter/widgets.dart';
extension WidgetPadding on Widget {
Widget padded([EdgeInsetsGeometry value = const EdgeInsets.all(8)]) {
return Padding(
padding: value,
child: this,
);
}
}
extension WidgetsPadding on List<Widget> {
List<Widget> padded([EdgeInsetsGeometry value = const EdgeInsets.all(8)]) {
return map((child) => Padding(
padding: value,
child: child,
)).toList();
}
}

View File

@@ -0,0 +1,40 @@
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:flutter_paperless_mobile/di_initializer.dart';
import 'package:flutter_paperless_mobile/extensions/flutter_extensions.dart';
import 'package:flutter_paperless_mobile/features/app_intro/widgets/biometric_authentication_intro_slide.dart';
import 'package:flutter_paperless_mobile/features/app_intro/widgets/configuration_done_intro_slide.dart';
import 'package:flutter_paperless_mobile/features/app_intro/widgets/welcome_intro_slide.dart';
import 'package:flutter_paperless_mobile/features/home/view/home_page.dart';
import 'package:flutter_paperless_mobile/features/settings/bloc/application_settings_cubit.dart';
import 'package:intro_slider/intro_slider.dart';
class ApplicationIntroSlideshow extends StatelessWidget {
const ApplicationIntroSlideshow({super.key});
@override
Widget build(BuildContext context) {
return WillPopScope(
onWillPop: () async => false,
child: IntroSlider(
renderDoneBtn: TextButton(
child: Text("GO"), //TODO: INTL
onPressed: () {
Navigator.pop(context);
},
),
backgroundColorAllTabs: Theme.of(context).canvasColor,
onDonePress: () => Navigator.of(context)
.pushReplacement(MaterialPageRoute(builder: (context) => const HomePage())),
listCustomTabs: [
const WelcomeIntroSlide(),
BlocProvider.value(
value: getIt<ApplicationSettingsCubit>(),
child: const BiometricAuthenticationIntroSlide(),
),
const ConfigurationDoneIntroSlide(),
].padded(const EdgeInsets.all(16.0)),
),
);
}
}

View File

@@ -0,0 +1,83 @@
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:flutter_paperless_mobile/di_initializer.dart';
import 'package:flutter_paperless_mobile/features/login/services/authentication.service.dart';
import 'package:flutter_paperless_mobile/features/settings/bloc/application_settings_cubit.dart';
import 'package:flutter_paperless_mobile/features/settings/model/application_settings_state.dart';
import 'package:flutter_paperless_mobile/util.dart';
class BiometricAuthenticationIntroSlide extends StatefulWidget {
const BiometricAuthenticationIntroSlide({
Key? key,
}) : super(key: key);
@override
State<BiometricAuthenticationIntroSlide> createState() =>
_BiometricAuthenticationIntroSlideState();
}
class _BiometricAuthenticationIntroSlideState extends State<BiometricAuthenticationIntroSlide> {
@override
Widget build(BuildContext context) {
//TODO: INTL
return BlocBuilder<ApplicationSettingsCubit, ApplicationSettingsState>(
builder: (context, settings) {
return Column(
mainAxisAlignment: MainAxisAlignment.spaceAround,
crossAxisAlignment: CrossAxisAlignment.center,
children: [
Text(
"Configure Biometric Authentication",
style: Theme.of(context).textTheme.titleLarge,
textAlign: TextAlign.center,
),
Text(
"It is highly recommended to additionally secure your local data. Do you want to enable biometric authentication?",
textAlign: TextAlign.center,
),
Column(
children: [
const Icon(
Icons.fingerprint,
size: 48,
),
const SizedBox(
height: 32,
),
Builder(builder: (context) {
if (settings.isLocalAuthenticationEnabled) {
return ElevatedButton.icon(
icon: Icon(
Icons.done,
color: Colors.green,
),
label: Text("Enabled"),
onPressed: null,
);
}
return ElevatedButton(
child: Text("Enable"),
onPressed: () {
final settings = BlocProvider.of<ApplicationSettingsCubit>(context).state;
getIt<AuthenticationService>()
.authenticateLocalUser("Please authenticate to secure Paperless Mobile")
.then((isEnabled) {
if (!isEnabled) {
showSnackBar(context,
"Could not set up biometric authentication. Please try again or skip for now.");
return;
}
BlocProvider.of<ApplicationSettingsCubit>(context)
.setIsBiometricAuthenticationEnabled(true);
});
},
);
}),
],
),
],
);
},
);
}
}

View File

@@ -0,0 +1,27 @@
import 'package:flutter/material.dart';
class ConfigurationDoneIntroSlide extends StatelessWidget {
const ConfigurationDoneIntroSlide({super.key});
@override
Widget build(BuildContext context) {
return Column(
//TODO: INTL
mainAxisAlignment: MainAxisAlignment.spaceAround,
children: [
Text(
"All set up!",
style: Theme.of(context).textTheme.titleLarge,
),
Icon(
Icons.emoji_emotions_outlined,
size: 64,
),
Text(
"You've successfully configured Paperless Mobile! Press 'GO' to get started managing your documents.",
textAlign: TextAlign.center,
),
],
);
}
}

View File

@@ -0,0 +1,26 @@
import 'package:flutter/material.dart';
import 'package:flutter/src/widgets/framework.dart';
class WelcomeIntroSlide extends StatelessWidget {
const WelcomeIntroSlide({super.key});
@override
Widget build(BuildContext context) {
//TODO: INTL
return Column(
mainAxisAlignment: MainAxisAlignment.spaceAround,
crossAxisAlignment: CrossAxisAlignment.center,
children: [
Text(
"Welcome to Paperless Mobile!",
style: Theme.of(context).textTheme.titleLarge,
textAlign: TextAlign.center,
),
Text(
"Manage and add your documents on the go!",
textAlign: TextAlign.center,
),
],
);
}
}

View File

@@ -0,0 +1,130 @@
import 'dart:typed_data';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:flutter_paperless_mobile/features/documents/bloc/documents_state.dart';
import 'package:flutter_paperless_mobile/features/documents/model/document.model.dart';
import 'package:flutter_paperless_mobile/features/documents/model/document_filter.dart';
import 'package:flutter_paperless_mobile/features/documents/model/paged_search_result.dart';
import 'package:flutter_paperless_mobile/features/documents/repository/document_repository.dart';
import 'package:injectable/injectable.dart';
@singleton
class DocumentsCubit extends Cubit<DocumentsState> {
final DocumentRepository documentRepository;
DocumentsCubit(this.documentRepository) : super(DocumentsState.initial);
Future<void> addDocument(
Uint8List bytes,
String fileName, {
required String title,
required void Function(DocumentModel document) onConsumptionFinished,
int? documentType,
int? correspondent,
List<int>? tags,
DateTime? createdAt,
}) async {
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) async {
await documentRepository.delete(document);
return await reloadDocuments();
}
Future<void> bulkRemoveDocuments(List<DocumentModel> documents) async {
await documentRepository.bulkDelete(documents);
return await reloadDocuments();
}
Future<void> updateDocument(DocumentModel document) async {
await documentRepository.update(document);
await reloadDocuments();
}
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() async {
if (state.currentPageNumber >= 5) {
return _bulkReloadDocuments();
}
var newPages = <PagedSearchResult>[];
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() 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() async {
if (state.isLastPageLoaded) {
return;
}
final newFilter = state.filter.copyWith(page: state.filter.page + 1);
final result = await documentRepository.find(newFilter);
emit(DocumentsState(isLoaded: true, value: [...state.value, result], filter: newFilter));
}
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({
DocumentFilter filter = DocumentFilter.initial,
}) async {
final result = await documentRepository.find(filter.copyWith(page: 1));
emit(DocumentsState(filter: filter, value: [result], isLoaded: true));
}
void toggleDocumentSelection(DocumentModel model) {
if (state.selection.contains(model)) {
emit(
state.copyWith(
selection: state.selection.where((element) => element.id != model.id).toList(),
),
);
} else {
emit(
state.copyWith(selection: [...state.selection, model]),
);
}
}
void resetSelection() {
emit(state.copyWith(selection: []));
}
void reset() {
emit(DocumentsState.initial);
}
}

View File

@@ -0,0 +1,82 @@
import 'package:equatable/equatable.dart';
import 'package:flutter_paperless_mobile/features/documents/model/document.model.dart';
import 'package:flutter_paperless_mobile/features/documents/model/document_filter.dart';
import 'package:flutter_paperless_mobile/features/documents/model/paged_search_result.dart';
class DocumentsState extends Equatable {
final bool isLoaded;
final DocumentFilter filter;
final List<PagedSearchResult> value;
final List<DocumentModel> selection;
const DocumentsState({
required this.isLoaded,
required this.value,
required this.filter,
this.selection = const [],
});
static const DocumentsState initial = DocumentsState(
isLoaded: false,
value: [],
filter: DocumentFilter.initial,
selection: [],
);
int get currentPageNumber {
return filter.page;
}
int? get nextPageNumber {
return isLastPageLoaded ? null : currentPageNumber + 1;
}
int get count {
if (value.isEmpty) {
return 0;
}
return value.first.count;
}
bool get isLastPageLoaded {
if (!isLoaded) {
return false;
}
if (value.isNotEmpty) {
return value.last.next == null;
}
return true;
}
int inferPageCount({required int pageSize}) {
if (!isLoaded) {
return 100000;
}
if (value.isEmpty) {
return 0;
}
return value.first.inferPageCount(pageSize: pageSize);
}
List<DocumentModel> get documents {
return value.fold([], (previousValue, element) => [...previousValue, ...element.results]);
}
DocumentsState copyWith({
bool overwrite = false,
bool? isLoaded,
List<PagedSearchResult>? value,
DocumentFilter? filter,
List<DocumentModel>? selection,
}) {
return DocumentsState(
isLoaded: isLoaded ?? this.isLoaded,
value: value ?? this.value,
filter: filter ?? this.filter,
selection: selection ?? this.selection,
);
}
@override
List<Object?> get props => [isLoaded, filter, value, selection];
}

View File

@@ -0,0 +1,50 @@
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:flutter_paperless_mobile/di_initializer.dart';
import 'package:flutter_paperless_mobile/features/documents/bloc/saved_view_state.dart';
import 'package:flutter_paperless_mobile/features/documents/model/saved_view.model.dart';
import 'package:flutter_paperless_mobile/features/documents/repository/saved_views_repository.dart';
import 'package:injectable/injectable.dart';
@singleton
class SavedViewCubit extends Cubit<SavedViewState> {
SavedViewCubit() : super(SavedViewState(value: {}));
void selectView(SavedView? view) {
emit(SavedViewState(value: state.value, selectedSavedViewId: view?.id));
}
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) 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() async {
final views = await getIt<SavedViewsRepository>().getAll();
final values = {for (var element in views) element.id!: element};
emit(SavedViewState(value: values));
}
void resetSelection() {
emit(SavedViewState(value: state.value));
}
}

View File

@@ -0,0 +1,15 @@
import 'package:equatable/equatable.dart';
import 'package:flutter_paperless_mobile/features/documents/model/saved_view.model.dart';
class SavedViewState with EquatableMixin {
final Map<int, SavedView> value;
final int? selectedSavedViewId;
SavedViewState({
required this.value,
this.selectedSavedViewId,
});
@override
List<Object?> get props => [value, selectedSavedViewId];
}

View File

@@ -0,0 +1,19 @@
import 'package:flutter_paperless_mobile/core/type/json.dart';
class BulkEditAction {
final List<int> documents;
final String _method;
final Map<String, dynamic> parameters;
BulkEditAction.delete(this.documents)
: _method = 'delete',
parameters = {};
JSON toJson() {
return {
'documents': documents,
'method': _method,
'parameters': parameters,
};
}
}

View File

@@ -0,0 +1,148 @@
// ignore_for_file: non_constant_identifier_names
import 'package:equatable/equatable.dart';
import 'package:flutter_paperless_mobile/core/type/json.dart';
import 'package:flutter_paperless_mobile/features/documents/model/query_parameters/id_query_parameter.dart';
import 'package:flutter_paperless_mobile/features/documents/model/query_parameters/ids_query_parameter.dart';
class DocumentModel extends Equatable {
static const idKey = 'id';
static const titleKey = "title";
static const contentKey = "content";
static const archivedFileNameKey = "archived_file_name";
static const asnKey = "archive_serial_number";
static const createdKey = "created";
static const modifiedKey = "modified";
static const addedKey = "added";
static const correspondentKey = "correspondent";
static const originalFileNameKey = 'original_file_name';
static const documentTypeKey = "document_type";
static const tagsKey = "tags";
static const storagePathKey = "storage_path";
final int id;
final String title;
final String? content;
final List<int> tags;
final int? documentType;
final int? correspondent;
final int? storagePath;
final DateTime created;
final DateTime modified;
final DateTime added;
final int? archiveSerialNumber;
final String originalFileName;
final String? archivedFileName;
const DocumentModel({
required this.id,
required this.title,
this.content,
this.tags = const <int>[],
required this.documentType,
required this.correspondent,
required this.created,
required this.modified,
required this.added,
this.archiveSerialNumber,
required this.originalFileName,
this.archivedFileName,
this.storagePath,
});
DocumentModel.fromJson(JSON json)
: id = json[idKey],
title = json[titleKey],
content = json[contentKey],
created = DateTime.parse(json[createdKey]),
modified = DateTime.parse(json[modifiedKey]),
added = DateTime.parse(json[addedKey]),
archiveSerialNumber = json[asnKey],
originalFileName = json[originalFileNameKey],
archivedFileName = json[archivedFileNameKey],
tags = (json[tagsKey] as List<dynamic>).cast<int>(),
correspondent = json[correspondentKey],
documentType = json[documentTypeKey],
storagePath = json[storagePathKey];
JSON toJson() {
return {
idKey: id,
titleKey: title,
asnKey: archiveSerialNumber,
archivedFileNameKey: archivedFileName,
contentKey: content,
correspondentKey: correspondent,
documentTypeKey: documentType,
createdKey: created.toUtc().toIso8601String(),
modifiedKey: modified.toUtc().toIso8601String(),
addedKey: added.toUtc().toIso8601String(),
originalFileNameKey: originalFileName,
tagsKey: tags,
storagePathKey: storagePath,
};
}
DocumentModel copyWith({
String? title,
String? content,
IdsQueryParameter? tags,
IdQueryParameter? documentType,
IdQueryParameter? correspondent,
IdQueryParameter? storagePath,
DateTime? created,
DateTime? modified,
DateTime? added,
int? archiveSerialNumber,
String? originalFileName,
String? archivedFileName,
}) {
return DocumentModel(
id: id,
title: title ?? this.title,
content: content ?? this.content,
documentType: fromQuery(documentType, this.documentType),
correspondent: fromQuery(correspondent, this.correspondent),
storagePath: fromQuery(storagePath, this.storagePath),
tags: fromListQuery(tags, this.tags),
created: created ?? this.created,
modified: modified ?? this.modified,
added: added ?? this.added,
originalFileName: originalFileName ?? this.originalFileName,
archiveSerialNumber: archiveSerialNumber ?? this.archiveSerialNumber,
archivedFileName: archivedFileName ?? this.archivedFileName,
);
}
int? fromQuery(IdQueryParameter? query, int? previous) {
if (query == null) {
return previous;
}
return query.id;
}
List<int> fromListQuery(IdsQueryParameter? query, List<int> previous) {
if (query == null) {
return previous;
}
return query.ids;
}
@override
List<Object?> get props => [
id,
title,
content,
tags,
documentType,
storagePath,
correspondent,
created,
modified,
added,
archiveSerialNumber,
originalFileName,
archivedFileName,
storagePath
];
}

View File

@@ -0,0 +1,168 @@
import 'package:equatable/equatable.dart';
import 'package:flutter_paperless_mobile/features/documents/model/query_parameters/asn_query.dart';
import 'package:flutter_paperless_mobile/features/documents/model/query_parameters/correspondent_query.dart';
import 'package:flutter_paperless_mobile/features/documents/model/query_parameters/document_type_query.dart';
import 'package:flutter_paperless_mobile/features/documents/model/query_parameters/sort_field.dart';
import 'package:flutter_paperless_mobile/features/documents/model/query_parameters/query_type.dart';
import 'package:flutter_paperless_mobile/features/documents/model/query_parameters/sort_order.dart';
import 'package:flutter_paperless_mobile/features/documents/model/query_parameters/storage_path_query.dart';
import 'package:flutter_paperless_mobile/features/documents/model/query_parameters/tags_query.dart';
import 'package:flutter_paperless_mobile/util.dart';
class DocumentFilter with EquatableMixin {
static const DocumentFilter initial = DocumentFilter();
static const DocumentFilter latestDocument = DocumentFilter(
sortField: SortField.added,
sortOrder: SortOrder.descending,
pageSize: 1,
page: 1,
);
final int pageSize;
final int page;
final DocumentTypeQuery documentType;
final CorrespondentQuery correspondent;
final StoragePathQuery storagePath;
final AsnQuery asn;
final TagsQuery tags;
final SortField sortField;
final SortOrder sortOrder;
final DateTime? addedDateAfter;
final DateTime? addedDateBefore;
final DateTime? createdDateAfter;
final DateTime? createdDateBefore;
final QueryType queryType;
final String? queryText;
const DocumentFilter({
this.createdDateAfter,
this.createdDateBefore,
this.documentType = const DocumentTypeQuery.unset(),
this.correspondent = const CorrespondentQuery.unset(),
this.storagePath = const StoragePathQuery.unset(),
this.asn = const AsnQuery.unset(),
this.tags = const TagsQuery.unset(),
this.sortField = SortField.created,
this.sortOrder = SortOrder.descending,
this.page = 1,
this.pageSize = 25,
this.addedDateAfter,
this.addedDateBefore,
this.queryType = QueryType.titleAndContent,
this.queryText,
});
String toQueryString() {
final StringBuffer sb = StringBuffer("page=$page&page_size=$pageSize");
sb.write(documentType.toQueryParameter());
sb.write(correspondent.toQueryParameter());
sb.write(tags.toQueryParameter());
sb.write(storagePath.toQueryParameter());
sb.write(asn.toQueryParameter());
if (queryText?.isNotEmpty ?? false) {
sb.write("&${queryType.queryParam}=$queryText");
}
sb.write("&ordering=${sortOrder.queryString}${sortField.queryString}");
if (addedDateAfter != null) {
sb.write("&added__date__gt=${dateFormat.format(addedDateAfter!)}");
}
if (addedDateBefore != null) {
sb.write("&added__date__lt=${dateFormat.format(addedDateBefore!)}");
}
if (createdDateAfter != null) {
sb.write("&created__date__gt=${dateFormat.format(createdDateAfter!)}");
}
if (createdDateBefore != null) {
sb.write("&created__date__lt=${dateFormat.format(createdDateBefore!)}");
}
return sb.toString();
}
@override
String toString() {
return toQueryString();
}
DocumentFilter copyWith({
int? pageSize,
int? page,
bool? onlyNoDocumentType,
DocumentTypeQuery? documentType,
CorrespondentQuery? correspondent,
StoragePathQuery? storagePath,
TagsQuery? tags,
SortField? sortField,
SortOrder? sortOrder,
DateTime? addedDateAfter,
DateTime? addedDateBefore,
DateTime? createdDateBefore,
DateTime? createdDateAfter,
QueryType? queryType,
String? queryText,
}) {
return DocumentFilter(
pageSize: pageSize ?? this.pageSize,
page: page ?? this.page,
documentType: documentType ?? this.documentType,
correspondent: correspondent ?? this.correspondent,
storagePath: storagePath ?? this.storagePath,
tags: tags ?? this.tags,
sortField: sortField ?? this.sortField,
sortOrder: sortOrder ?? this.sortOrder,
addedDateAfter: addedDateAfter ?? this.addedDateAfter,
addedDateBefore: addedDateBefore ?? this.addedDateBefore,
queryType: queryType ?? this.queryType,
queryText: queryText ?? this.queryText,
createdDateBefore: createdDateBefore ?? this.createdDateBefore,
createdDateAfter: createdDateAfter ?? this.createdDateAfter,
);
}
String? get titleOnlyMatchString {
if (queryType == QueryType.title) {
return queryText?.isEmpty ?? true ? null : queryText;
}
return null;
}
String? get titleAndContentMatchString {
if (queryType == QueryType.titleAndContent) {
return queryText?.isEmpty ?? true ? null : queryText;
}
return null;
}
String? get extendedMatchString {
if (queryType == QueryType.extended) {
return queryText?.isEmpty ?? true ? null : queryText;
}
return null;
}
@override
List<Object?> get props => [
pageSize,
page,
documentType,
correspondent,
storagePath,
asn,
tags,
sortField,
sortOrder,
addedDateAfter,
addedDateBefore,
createdDateAfter,
createdDateBefore,
queryType,
queryText,
];
}

View File

@@ -0,0 +1,40 @@
class DocumentMetaData {
String originalChecksum;
int originalSize;
String originalMimeType;
String mediaFilename;
bool hasArchiveVersion;
String? archiveChecksum;
int? archiveSize;
DocumentMetaData({
required this.originalChecksum,
required this.originalSize,
required this.originalMimeType,
required this.mediaFilename,
required this.hasArchiveVersion,
this.archiveChecksum,
this.archiveSize,
});
DocumentMetaData.fromJson(Map<String, dynamic> json)
: originalChecksum = json['original_checksum'],
originalSize = json['original_size'],
originalMimeType = json['original_mime_type'],
mediaFilename = json['media_filename'],
hasArchiveVersion = json['has_archive_version'],
archiveChecksum = json['archive_checksum'],
archiveSize = json['archive_size'];
Map<String, dynamic> toJson() {
final data = <String, dynamic>{};
data['original_checksum'] = originalChecksum;
data['original_size'] = originalSize;
data['original_mime_type'] = originalMimeType;
data['media_filename'] = mediaFilename;
data['has_archive_version'] = hasArchiveVersion;
data['archive_checksum'] = archiveChecksum;
data['archive_size'] = archiveSize;
return data;
}
}

View File

@@ -0,0 +1,166 @@
import 'package:equatable/equatable.dart';
import 'package:flutter_paperless_mobile/core/type/json.dart';
import 'package:flutter_paperless_mobile/features/documents/model/document_filter.dart';
import 'package:flutter_paperless_mobile/features/documents/model/query_parameters/correspondent_query.dart';
import 'package:flutter_paperless_mobile/features/documents/model/query_parameters/document_type_query.dart';
import 'package:flutter_paperless_mobile/features/documents/model/query_parameters/query_type.dart';
import 'package:flutter_paperless_mobile/features/documents/model/query_parameters/storage_path_query.dart';
import 'package:flutter_paperless_mobile/features/documents/model/query_parameters/tags_query.dart';
import 'package:flutter_paperless_mobile/util.dart';
class FilterRule with EquatableMixin {
static const int titleRule = 0;
static const int asnRule = 2;
static const int correspondentRule = 3;
static const int documentTypeRule = 4;
static const int tagRule = 6;
static const int createdBeforeRule = 8;
static const int createdAfterRule = 9;
static const int addedBeforeRule = 13;
static const int addedAfterRule = 14;
static const int titleAndContentRule = 19;
static const int extendedRule = 20;
static const int storagePathRule = 25;
// Currently unsupported view optiosn:
static const int _content = 1;
static const int _isInInbox = 5;
static const int _hasAnyTag = 7;
static const int _createdYearIs = 10;
static const int _createdMonthIs = 11;
static const int _createdDayIs = 12;
static const int _modifiedBefore = 15;
static const int _modifiedAfter = 16;
static const int _doesNotHaveTag = 17;
static const int _doesNotHaveAsn = 18;
static const int _moreLikeThis = 21;
static const int _hasTagsIn = 22;
static const int _asnGreaterThan = 23;
static const int _asnLessThan = 24;
final int ruleType;
final String? value;
FilterRule(this.ruleType, this.value);
FilterRule.fromJson(JSON json)
: ruleType = json['rule_type'],
value = json['value'];
JSON toJson() {
return {
'rule_type': ruleType,
'value': value,
};
}
DocumentFilter applyToFilter(final DocumentFilter filter) {
//TODO: Check in profiling mode if this is inefficient enough to cause stutters...
switch (ruleType) {
case titleRule:
return filter.copyWith(queryText: value, queryType: QueryType.title);
case documentTypeRule:
return filter.copyWith(
documentType: value == null
? const DocumentTypeQuery.notAssigned()
: DocumentTypeQuery.fromId(int.parse(value!)),
);
case correspondentRule:
return filter.copyWith(
correspondent: value == null
? const CorrespondentQuery.notAssigned()
: CorrespondentQuery.fromId(int.parse(value!)),
);
case storagePathRule:
return filter.copyWith(
storagePath: value == null
? const StoragePathQuery.notAssigned()
: StoragePathQuery.fromId(int.parse(value!)),
);
case tagRule:
return filter.copyWith(
tags: value == null
? const TagsQuery.notAssigned()
: TagsQuery.fromIds([...filter.tags.ids, int.parse(value!)]),
);
case createdBeforeRule:
return filter.copyWith(createdDateBefore: value == null ? null : DateTime.parse(value!));
case createdAfterRule:
return filter.copyWith(createdDateAfter: value == null ? null : DateTime.parse(value!));
case addedBeforeRule:
return filter.copyWith(addedDateBefore: value == null ? null : DateTime.parse(value!));
case addedAfterRule:
return filter.copyWith(addedDateAfter: value == null ? null : DateTime.parse(value!));
case titleAndContentRule:
return filter.copyWith(queryText: value, queryType: QueryType.titleAndContent);
case extendedRule:
return filter.copyWith(queryText: value, queryType: QueryType.extended);
//TODO: Add currently unused rules
default:
return filter;
}
}
///
/// Converts a [DocumentFilter] to a list of [FilterRule]s.
///
static List<FilterRule> fromFilter(final DocumentFilter filter) {
List<FilterRule> filterRules = [];
if (filter.correspondent.onlyNotAssigned) {
filterRules.add(FilterRule(correspondentRule, null));
}
if (filter.correspondent.isSet) {
filterRules.add(FilterRule(correspondentRule, filter.correspondent.id!.toString()));
}
if (filter.documentType.onlyNotAssigned) {
filterRules.add(FilterRule(documentTypeRule, null));
}
if (filter.documentType.isSet) {
filterRules.add(FilterRule(documentTypeRule, filter.documentType.id!.toString()));
}
if (filter.storagePath.onlyNotAssigned) {
filterRules.add(FilterRule(storagePathRule, null));
}
if (filter.storagePath.isSet) {
filterRules.add(FilterRule(storagePathRule, filter.storagePath.id!.toString()));
}
if (filter.tags.onlyNotAssigned) {
filterRules.add(FilterRule(tagRule, null));
}
if (filter.tags.isSet) {
filterRules.addAll(filter.tags.ids.map((id) => FilterRule(tagRule, id.toString())));
}
if (filter.queryText != null) {
switch (filter.queryType) {
case QueryType.title:
filterRules.add(FilterRule(titleRule, filter.queryText!));
break;
case QueryType.titleAndContent:
filterRules.add(FilterRule(titleAndContentRule, filter.queryText!));
break;
case QueryType.extended:
filterRules.add(FilterRule(extendedRule, filter.queryText!));
break;
case QueryType.asn:
filterRules.add(FilterRule(asnRule, filter.queryText!));
break;
}
}
if (filter.createdDateAfter != null) {
filterRules.add(FilterRule(createdAfterRule, dateFormat.format(filter.createdDateAfter!)));
}
if (filter.createdDateBefore != null) {
filterRules.add(FilterRule(createdBeforeRule, dateFormat.format(filter.createdDateBefore!)));
}
if (filter.addedDateAfter != null) {
filterRules.add(FilterRule(addedAfterRule, dateFormat.format(filter.addedDateAfter!)));
}
if (filter.addedDateBefore != null) {
filterRules.add(FilterRule(addedBeforeRule, dateFormat.format(filter.addedDateBefore!)));
}
return filterRules;
}
@override
List<Object?> get props => [ruleType, value];
}

View File

@@ -0,0 +1,84 @@
import 'package:equatable/equatable.dart';
import 'package:flutter_paperless_mobile/core/type/json.dart';
import 'package:flutter_paperless_mobile/features/documents/model/document.model.dart';
const pageRegex = r".*page=(\d+).*";
class PagedSearchResult<T> extends Equatable {
/// Total number of available items
final int count;
/// Link to next page
final String? next;
/// Link to previous page
final String? previous;
/// Actual items
final List<T> results;
int get pageKey {
if (next != null) {
final matches = RegExp(pageRegex).allMatches(next!);
final group = matches.first.group(1)!;
final nextPageKey = int.parse(group);
return nextPageKey - 1;
}
if (previous != null) {
// This is only executed if it's the last page or there is no data.
final matches = RegExp(pageRegex).allMatches(previous!);
if (matches.isEmpty) {
//In case there is a match but a page is not explicitly set, the page is 1 per default. Therefore, if the previous page is 1, this page is 1+1=2
return 2;
}
final group = matches.first.group(1)!;
final previousPageKey = int.parse(group);
return previousPageKey + 1;
}
return 1;
}
const PagedSearchResult({
required this.count,
required this.next,
required this.previous,
required this.results,
});
factory PagedSearchResult.fromJson(Map<dynamic, dynamic> json, T Function(JSON) fromJson) {
return PagedSearchResult(
count: json['count'],
next: json['next'],
previous: json['previous'],
results: List<JSON>.from(json['results']).map<T>(fromJson).toList(),
);
}
PagedSearchResult copyWith({
int? count,
String? next,
String? previous,
List<DocumentModel>? results,
}) {
return PagedSearchResult(
count: count ?? this.count,
next: next ?? this.next,
previous: previous ?? this.previous,
results: results ?? this.results,
);
}
///
/// Returns the number of pages based on the given [pageSize]. The last page
/// might not exhaust its capacity.
///
int inferPageCount({required int pageSize}) {
if (pageSize == 0) {
return 0;
}
return (count / pageSize).round() + 1;
}
@override
List<Object?> get props => [count, next, previous, results];
}

View File

@@ -0,0 +1,10 @@
import 'package:flutter_paperless_mobile/features/documents/model/query_parameters/id_query_parameter.dart';
class AsnQuery extends IdQueryParameter {
const AsnQuery.fromId(super.id) : super.fromId();
const AsnQuery.unset() : super.unset();
const AsnQuery.notAssigned() : super.notAssigned();
@override
String get queryParameterKey => 'archive_serial_number';
}

View File

@@ -0,0 +1,10 @@
import 'package:flutter_paperless_mobile/features/documents/model/query_parameters/id_query_parameter.dart';
class CorrespondentQuery extends IdQueryParameter {
const CorrespondentQuery.fromId(super.id) : super.fromId();
const CorrespondentQuery.unset() : super.unset();
const CorrespondentQuery.notAssigned() : super.notAssigned();
@override
String get queryParameterKey => 'correspondent';
}

View File

@@ -0,0 +1,10 @@
import 'package:flutter_paperless_mobile/features/documents/model/query_parameters/id_query_parameter.dart';
class DocumentTypeQuery extends IdQueryParameter {
const DocumentTypeQuery.fromId(super.id) : super.fromId();
const DocumentTypeQuery.unset() : super.unset();
const DocumentTypeQuery.notAssigned() : super.notAssigned();
@override
String get queryParameterKey => 'document_type';
}

View File

@@ -0,0 +1,39 @@
import 'package:equatable/equatable.dart';
import 'package:flutter/widgets.dart';
abstract class IdQueryParameter extends Equatable {
final bool _onlyNotAssigned;
final int? _id;
const IdQueryParameter.notAssigned()
: _onlyNotAssigned = true,
_id = null;
const IdQueryParameter.fromId(int? id)
: _onlyNotAssigned = false,
_id = id;
const IdQueryParameter.unset() : this.fromId(null);
bool get isUnset => _id == null && _onlyNotAssigned == false;
bool get isSet => _id != null && _onlyNotAssigned == false;
bool get onlyNotAssigned => _onlyNotAssigned;
int? get id => _id;
@protected
String get queryParameterKey;
String toQueryParameter() {
if (onlyNotAssigned) {
return "&${queryParameterKey}__isnull=1";
}
return isUnset ? "" : "&${queryParameterKey}__id=$id";
}
@override
List<Object?> get props => [_onlyNotAssigned, _id];
}

View File

@@ -0,0 +1,29 @@
import 'package:equatable/equatable.dart';
abstract class IdsQueryParameter with EquatableMixin {
final List<int> _ids;
final bool onlyNotAssigned;
const IdsQueryParameter.fromIds(List<int> ids)
: onlyNotAssigned = false,
_ids = ids;
const IdsQueryParameter.notAssigned()
: onlyNotAssigned = true,
_ids = const [];
const IdsQueryParameter.unset()
: onlyNotAssigned = false,
_ids = const [];
bool get isUnset => _ids.isEmpty && onlyNotAssigned == false;
bool get isSet => _ids.isNotEmpty && onlyNotAssigned == false;
List<int> get ids => _ids;
String toQueryParameter();
@override
List<Object?> get props => [onlyNotAssigned, _ids];
}

View File

@@ -0,0 +1,9 @@
enum QueryType {
title('title__icontains'),
titleAndContent('title_content'),
extended('query'),
asn('asn');
final String queryParam;
const QueryType(this.queryParam);
}

View File

@@ -0,0 +1,18 @@
enum SortField {
archiveSerialNumber("archive_serial_number"),
correspondentName("correspondent__name"),
title("title"),
documentType("documentType"),
created("created"),
added("added"),
modified("modified");
final String queryString;
const SortField(this.queryString);
@override
String toString() {
return name.toLowerCase();
}
}

View File

@@ -0,0 +1,11 @@
enum SortOrder {
ascending(""),
descending("-");
final String queryString;
const SortOrder(this.queryString);
SortOrder toggle() {
return this == ascending ? descending : ascending;
}
}

View File

@@ -0,0 +1,10 @@
import 'package:flutter_paperless_mobile/features/documents/model/query_parameters/id_query_parameter.dart';
class StoragePathQuery extends IdQueryParameter {
const StoragePathQuery.fromId(super.id) : super.fromId();
const StoragePathQuery.unset() : super.unset();
const StoragePathQuery.notAssigned() : super.notAssigned();
@override
String get queryParameterKey => 'storage_path';
}

View File

@@ -0,0 +1,15 @@
import 'package:flutter_paperless_mobile/features/documents/model/query_parameters/ids_query_parameter.dart';
class TagsQuery extends IdsQueryParameter {
const TagsQuery.fromIds(super.ids) : super.fromIds();
const TagsQuery.unset() : super.unset();
const TagsQuery.notAssigned() : super.notAssigned();
@override
String toQueryParameter() {
if (onlyNotAssigned) {
return '&is_tagged=false';
}
return isUnset ? "" : '&tags__id__all=${ids.join(',')}';
}
}

View File

@@ -0,0 +1,88 @@
import 'package:equatable/equatable.dart';
import 'package:flutter_paperless_mobile/core/type/json.dart';
import 'package:flutter_paperless_mobile/features/documents/model/document_filter.dart';
import 'package:flutter_paperless_mobile/features/documents/model/filter_rule.model.dart';
import 'package:flutter_paperless_mobile/features/documents/model/query_parameters/sort_field.dart';
import 'package:flutter_paperless_mobile/features/documents/model/query_parameters/sort_order.dart';
class SavedView with EquatableMixin {
final int? id;
final String name;
final bool showOnDashboard;
final bool showInSidebar;
final SortField sortField;
final bool sortReverse;
final List<FilterRule> filterRules;
SavedView({
this.id,
required this.name,
required this.showOnDashboard,
required this.showInSidebar,
required this.sortField,
required this.sortReverse,
required this.filterRules,
}) {
filterRules.sort(
(a, b) => (a.ruleType.compareTo(b.ruleType) != 0
? a.ruleType.compareTo(b.ruleType)
: a.value?.compareTo(b.value ?? "") ?? -1),
);
}
@override
List<Object?> get props =>
[name, showOnDashboard, showInSidebar, sortField, sortReverse, filterRules];
SavedView.fromJson(JSON json)
: this(
id: json['id'],
name: json['name'],
showOnDashboard: json['show_on_dashboard'],
showInSidebar: json['show_in_sidebar'],
sortField:
SortField.values.where((order) => order.queryString == json['sort_field']).first,
sortReverse: json['sort_reverse'],
filterRules:
json['filter_rules'].cast<JSON>().map<FilterRule>(FilterRule.fromJson).toList(),
);
DocumentFilter toDocumentFilter() {
return filterRules.fold(
DocumentFilter(
sortOrder: sortReverse ? SortOrder.ascending : SortOrder.descending,
sortField: sortField,
),
(filter, filterRule) => filterRule.applyToFilter(filter),
);
}
SavedView.fromDocumentFilter(
DocumentFilter filter, {
required String name,
required bool showInSidebar,
required bool showOnDashboard,
}) : this(
id: null,
name: name,
filterRules: FilterRule.fromFilter(filter),
sortField: filter.sortField,
showInSidebar: showInSidebar,
showOnDashboard: showOnDashboard,
sortReverse: filter.sortOrder == SortOrder.ascending,
);
JSON toJson() {
return {
'id': id,
'name': name,
'show_on_dashboard': showOnDashboard,
'show_in_sidebar': showInSidebar,
'sort_reverse': sortReverse,
'sort_field': sortField.queryString,
'filter_rules': filterRules.map((rule) => rule.toJson()).toList(),
};
}
}

View File

@@ -0,0 +1,59 @@
import 'package:flutter_paperless_mobile/core/type/json.dart';
import 'package:flutter_paperless_mobile/features/documents/model/document.model.dart';
class SimilarDocumentModel extends DocumentModel {
final SearchHit searchHit;
const SimilarDocumentModel({
required super.id,
required super.title,
required super.documentType,
required super.correspondent,
required super.created,
required super.modified,
required super.added,
required super.originalFileName,
required this.searchHit,
super.archiveSerialNumber,
super.archivedFileName,
super.content,
super.storagePath,
super.tags,
});
@override
JSON toJson() {
final json = super.toJson();
json['__search_hit__'] = searchHit.toJson();
return json;
}
SimilarDocumentModel.fromJson(JSON json)
: searchHit = SearchHit.fromJson(json),
super.fromJson(json);
}
class SearchHit {
final double? score;
final String? highlights;
final int? rank;
SearchHit({
this.score,
required this.highlights,
required this.rank,
});
JSON toJson() {
return {
'score': score,
'highlights': highlights,
'rank': rank,
};
}
SearchHit.fromJson(JSON json)
: score = json['score'],
highlights = json['highlights'],
rank = json['rank'];
}

View File

@@ -0,0 +1,33 @@
import 'dart:typed_data';
import 'package:flutter_paperless_mobile/features/documents/model/document.model.dart';
import 'package:flutter_paperless_mobile/features/documents/model/document_filter.dart';
import 'package:flutter_paperless_mobile/features/documents/model/document_meta_data.model.dart';
import 'package:flutter_paperless_mobile/features/documents/model/saved_view.model.dart';
import 'package:flutter_paperless_mobile/features/documents/model/paged_search_result.dart';
import 'package:flutter_paperless_mobile/features/documents/model/similar_document.model.dart';
abstract class DocumentRepository {
Future<void> create(
Uint8List documentBytes,
String filename, {
required String title,
int? documentType,
int? correspondent,
List<int>? tags,
DateTime? createdAt,
});
Future<DocumentModel> update(DocumentModel doc);
Future<int> findNextAsn();
Future<PagedSearchResult> find(DocumentFilter filter);
Future<List<SimilarDocumentModel>> findSimilar(int docId);
Future<int> delete(DocumentModel doc);
Future<DocumentMetaData> getMetaData(DocumentModel document);
Future<List<int>> bulkDelete(List<DocumentModel> models);
Future<Uint8List> getPreview(int docId);
String getThumbnailUrl(int docId);
Future<DocumentModel> waitForConsumptionFinished(String filename, String title);
Future<Uint8List> download(DocumentModel document);
Future<List<String>> autocomplete(String query, [int limit = 10]);
}

View File

@@ -0,0 +1,275 @@
import 'dart:async';
import 'dart:convert';
import 'dart:io';
import 'dart:math';
import 'dart:typed_data';
import 'package:flutter/foundation.dart';
import 'package:flutter_paperless_mobile/core/model/error_message.dart';
import 'package:flutter_paperless_mobile/core/store/local_vault.dart';
import 'package:flutter_paperless_mobile/core/type/json.dart';
import 'package:flutter_paperless_mobile/core/util.dart';
import 'package:flutter_paperless_mobile/di_initializer.dart';
import 'package:flutter_paperless_mobile/extensions/dart_extensions.dart';
import 'package:flutter_paperless_mobile/features/documents/model/bulk_edit.model.dart';
import 'package:flutter_paperless_mobile/features/documents/model/document.model.dart';
import 'package:flutter_paperless_mobile/features/documents/model/document_filter.dart';
import 'package:flutter_paperless_mobile/features/documents/model/document_meta_data.model.dart';
import 'package:flutter_paperless_mobile/features/documents/model/paged_search_result.dart';
import 'package:flutter_paperless_mobile/features/documents/model/query_parameters/sort_field.dart';
import 'package:flutter_paperless_mobile/features/documents/model/query_parameters/sort_order.dart';
import 'package:flutter_paperless_mobile/features/documents/model/similar_document.model.dart';
import 'package:flutter_paperless_mobile/features/documents/repository/document_repository.dart';
import 'package:flutter_paperless_mobile/util.dart';
import 'package:http/http.dart';
import 'package:http/src/boundary_characters.dart'; //TODO: remove once there is either a paperless API update or there is a better solution...
import 'package:injectable/injectable.dart';
@Injectable(as: DocumentRepository)
class DocumentRepositoryImpl implements DocumentRepository {
////
//final StatusService statusService;
final LocalVault localStorage;
final BaseClient httpClient;
DocumentRepositoryImpl(
//this.statusService,
this.localStorage,
@Named("timeoutClient") this.httpClient,
);
@override
Future<void> create(
Uint8List documentBytes,
String filename, {
required String title,
int? documentType,
int? correspondent,
List<int>? tags,
DateTime? createdAt,
}) async {
final auth = await localStorage.loadAuthenticationInformation();
if (auth == null) {
throw const ErrorMessage(ErrorCode.notAuthenticated);
}
// The multipart request has to be generated from scratch as the http library does
// not allow the same key (tags) to be added multiple times. However, this is what the
// paperless api expects, i.e. one block for each tag.
final request = await getIt<HttpClient>().postUrl(
Uri.parse("${auth.serverUrl}/api/documents/post_document/"),
);
final boundary = _boundaryString();
StringBuffer bodyBuffer = StringBuffer();
var fields = <String, String>{};
fields.tryPutIfAbsent('title', () => title);
fields.tryPutIfAbsent('created', () => formatDateNullable(createdAt));
fields.tryPutIfAbsent(
'correspondent', () => correspondent == null ? null : json.encode(correspondent));
fields.tryPutIfAbsent(
'document_type', () => documentType == null ? null : json.encode(documentType));
for (final key in fields.keys) {
bodyBuffer.write(_buildMultipartField(key, fields[key]!, boundary));
}
for (final tag in tags ?? <int>[]) {
bodyBuffer.write(_buildMultipartField('tags', tag.toString(), boundary));
}
bodyBuffer.write("--$boundary"
'\r\nContent-Disposition: form-data; name="document"; filename="$filename"'
"\r\nContent-type: application/octet-stream"
"\r\n\r\n");
final closing = "\r\n--" + boundary + "--\r\n";
// Set headers
request.headers.set(HttpHeaders.contentTypeHeader, "multipart/form-data; boundary=" + boundary);
request.headers.set(HttpHeaders.contentLengthHeader,
"${bodyBuffer.length + closing.length + documentBytes.lengthInBytes}");
request.headers.set(HttpHeaders.authorizationHeader, "Token ${auth.token}");
//Write fields to request
request.write(bodyBuffer.toString());
//Stream file
await request.addStream(Stream.fromIterable(documentBytes.map((e) => [e])));
// Write closing boundary to request
request.write(closing);
final response = await request.close();
if (response.statusCode != 200) {
throw ErrorMessage(ErrorCode.documentUploadFailed, httpStatusCode: response.statusCode);
}
}
String _buildMultipartField(String fieldName, String value, String boundary) {
return '--$boundary'
'\r\nContent-Disposition: form-data; name="$fieldName"'
'\r\nContent-type: text/plain'
'\r\n\r\n' +
value +
'\r\n';
}
String _boundaryString() {
Random _random = Random();
var prefix = 'dart-http-boundary-';
var list = List<int>.generate(70 - prefix.length,
(index) => boundaryCharacters[_random.nextInt(boundaryCharacters.length)],
growable: false);
return '$prefix${String.fromCharCodes(list)}';
}
@override
Future<DocumentModel> update(DocumentModel doc) async {
final response = await httpClient.put(Uri.parse("/api/documents/${doc.id}/"),
body: json.encode(doc.toJson()),
headers: {"Content-Type": "application/json"}).timeout(requestTimeout);
if (response.statusCode == 200) {
return DocumentModel.fromJson(jsonDecode(response.body));
} else {
throw const ErrorMessage(ErrorCode.documentUpdateFailed);
}
}
@override
Future<PagedSearchResult<DocumentModel>> find(DocumentFilter filter) async {
final filterParams = filter.toQueryString();
final response = await httpClient.get(
Uri.parse("/api/documents/?$filterParams"),
);
if (response.statusCode == 200) {
final searchResult = PagedSearchResult.fromJson(
jsonDecode(const Utf8Decoder().convert(response.body.codeUnits)),
DocumentModel.fromJson,
);
return searchResult;
} else {
throw const ErrorMessage(ErrorCode.documentLoadFailed);
}
}
@override
Future<int> delete(DocumentModel doc) async {
final response = await httpClient.delete(Uri.parse("/api/documents/${doc.id}/"));
if (response.statusCode == 204) {
return Future.value(doc.id);
}
throw const ErrorMessage(ErrorCode.documentDeleteFailed);
}
@override
String getThumbnailUrl(int documentId) {
return "/api/documents/$documentId/thumb/";
}
String getPreviewUrl(int documentId) {
return "/api/documents/$documentId/preview/";
}
@override
Future<Uint8List> getPreview(int documentId) async {
final response = await httpClient.get(Uri.parse(getPreviewUrl(documentId)));
if (response.statusCode == 200) {
return response.bodyBytes;
}
throw const ErrorMessage(ErrorCode.documentPreviewFailed);
}
@override
Future<int> findNextAsn() async {
const DocumentFilter asnQueryFilter = DocumentFilter(
sortField: SortField.archiveSerialNumber,
sortOrder: SortOrder.descending,
page: 1,
pageSize: 1,
);
try {
final result = await find(asnQueryFilter);
return result.results
.map((e) => e.archiveSerialNumber)
.firstWhere((asn) => asn != null, orElse: () => 0)! +
1;
} on ErrorMessage catch (_) {
throw const ErrorMessage(ErrorCode.documentAsnQueryFailed);
}
}
@override
Future<List<int>> bulkDelete(List<DocumentModel> documentModels) async {
final List<int> ids = documentModels.map((e) => e.id).toList();
final action = BulkEditAction.delete(ids);
final response = await httpClient.post(
Uri.parse("/api/documents/bulk_edit/"),
body: json.encode(action.toJson()),
headers: {'Content-Type': 'application/json'},
);
if (response.statusCode == 200) {
return ids;
} else {
throw const ErrorMessage(ErrorCode.documentBulkDeleteFailed);
}
}
@override
Future<DocumentModel> waitForConsumptionFinished(String fileName, String title) async {
// Always wait 5 seconds, processing usually takes longer...
//await Future.delayed(const Duration(seconds: 5));
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 ErrorMessage(ErrorCode.documentUploadFailed);
}
}
@override
Future<Uint8List> download(DocumentModel document) async {
//TODO: Check if this works...
final response = await httpClient.get(Uri.parse("/api/documents/${document.id}/download/"));
return response.bodyBytes;
}
@override
Future<DocumentMetaData> getMetaData(DocumentModel document) async {
final response = await httpClient.get(Uri.parse("/api/documents/${document.id}/metadata/"));
return DocumentMetaData.fromJson(jsonDecode(response.body));
}
@override
Future<List<String>> autocomplete(String query, [int limit = 10]) async {
final response =
await httpClient.get(Uri.parse("/api/search/autocomplete/?query=$query&limit=$limit}"));
if (response.statusCode == 200) {
return json.decode(response.body) as List<String>;
}
throw const ErrorMessage(ErrorCode.autocompleteQueryError);
}
@override
Future<List<SimilarDocumentModel>> findSimilar(int docId) async {
final response =
await httpClient.get(Uri.parse("/api/documents/?more_like=$docId&pageSize=10"));
if (response.statusCode == 200) {
return PagedSearchResult<SimilarDocumentModel>.fromJson(
json.decode(response.body),
SimilarDocumentModel.fromJson,
).results;
}
throw const ErrorMessage(ErrorCode.similarQueryError);
}
}

View File

@@ -0,0 +1,53 @@
import 'dart:convert';
import 'package:flutter_paperless_mobile/core/model/error_message.dart';
import 'package:flutter_paperless_mobile/core/util.dart';
import 'package:flutter_paperless_mobile/di_initializer.dart';
import 'package:flutter_paperless_mobile/features/documents/model/saved_view.model.dart';
import 'package:http/http.dart';
import 'package:injectable/injectable.dart';
abstract class SavedViewsRepository {
Future<List<SavedView>> getAll();
Future<SavedView> save(SavedView view);
Future<int> delete(SavedView view);
}
@Injectable(as: SavedViewsRepository)
class SavedViewRepositoryImpl implements SavedViewsRepository {
final BaseClient httpClient;
SavedViewRepositoryImpl(@Named("timeoutClient") this.httpClient);
@override
Future<List<SavedView>> getAll() {
return getCollection(
"/api/saved_views/",
SavedView.fromJson,
ErrorCode.loadSavedViewsError,
);
}
@override
Future<SavedView> save(SavedView view) async {
final response = await httpClient.post(
Uri.parse("/api/saved_views/"),
body: jsonEncode(view.toJson()),
headers: {'Content-Type': 'application/json'},
);
if (response.statusCode == 201) {
return SavedView.fromJson(jsonDecode(response.body));
}
throw ErrorMessage(ErrorCode.createSavedViewError, httpStatusCode: response.statusCode);
}
@override
Future<int> delete(SavedView view) async {
final response = await httpClient.delete(Uri.parse("/api/saved_views/${view.id}/"));
if (response.statusCode == 204) {
return view.id!;
}
throw ErrorMessage(ErrorCode.deleteSavedViewError, httpStatusCode: response.statusCode);
}
}

View File

@@ -0,0 +1,418 @@
import 'dart:io';
import 'dart:math';
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:flutter_paperless_mobile/core/bloc/label_bloc_provider.dart';
import 'package:flutter_paperless_mobile/core/logic/error_code_localization_mapper.dart';
import 'package:flutter_paperless_mobile/core/model/error_message.dart';
import 'package:flutter_paperless_mobile/core/widgets/highlighted_text.dart';
import 'package:flutter_paperless_mobile/di_initializer.dart';
import 'package:flutter_paperless_mobile/extensions/flutter_extensions.dart';
import 'package:flutter_paperless_mobile/features/documents/bloc/documents_cubit.dart';
import 'package:flutter_paperless_mobile/features/documents/bloc/documents_state.dart';
import 'package:flutter_paperless_mobile/features/documents/model/document.model.dart';
import 'package:flutter_paperless_mobile/features/documents/model/document_meta_data.model.dart';
import 'package:flutter_paperless_mobile/features/documents/repository/document_repository.dart';
import 'package:flutter_paperless_mobile/features/documents/view/pages/document_edit_page.dart';
import 'package:flutter_paperless_mobile/features/documents/view/pages/document_view.dart';
import 'package:flutter_paperless_mobile/features/documents/view/widgets/delete_document_confirmation_dialog.dart';
import 'package:flutter_paperless_mobile/features/documents/view/widgets/document_preview.dart';
import 'package:flutter_paperless_mobile/features/labels/correspondent/view/widgets/correspondent_widget.dart';
import 'package:flutter_paperless_mobile/features/labels/document_type/view/widgets/document_type_widget.dart';
import 'package:flutter_paperless_mobile/features/labels/storage_path/view/widgets/storage_path_widget.dart';
import 'package:flutter_paperless_mobile/features/labels/tags/view/widgets/tags_widget.dart';
import 'package:flutter_paperless_mobile/generated/l10n.dart';
import 'package:flutter_paperless_mobile/util.dart';
import 'package:intl/intl.dart';
import 'package:path_provider/path_provider.dart';
class DocumentDetailsPage extends StatefulWidget {
final int documentId;
const DocumentDetailsPage({
Key? key,
required this.documentId,
}) : super(key: key);
@override
State<DocumentDetailsPage> createState() => _DocumentDetailsPageState();
}
class _DocumentDetailsPageState extends State<DocumentDetailsPage> {
static final DateFormat _detailedDateFormat = DateFormat("MMM d, yyyy HH:mm:ss");
bool _isDownloadPending = false;
bool _isAssignAsnPending = false;
@override
Widget build(BuildContext context) {
return BlocBuilder<DocumentsCubit, DocumentsState>(
// buildWhen required because rebuild would happen after delete causing error.
buildWhen: (previous, current) {
return current.documents.where((element) => element.id == widget.documentId).isNotEmpty;
},
builder: (context, state) {
final document = state.documents.where((doc) => doc.id == widget.documentId).first;
return SafeArea(
bottom: true,
child: DefaultTabController(
length: 3,
child: Scaffold(
floatingActionButtonLocation: FloatingActionButtonLocation.endDocked,
floatingActionButton: FloatingActionButton(
child: const Icon(Icons.edit),
onPressed: () => _onEdit(document),
),
bottomNavigationBar: BottomAppBar(
child: Row(
mainAxisAlignment: MainAxisAlignment.start,
children: [
IconButton(
icon: const Icon(Icons.delete),
onPressed: () => _onDelete(document),
).padded(const EdgeInsets.symmetric(horizontal: 8.0)),
IconButton(
icon: const Icon(Icons.download),
onPressed: null, //() => _onDownload(document), //TODO: FIX
),
IconButton(
icon: const Icon(Icons.open_in_new),
onPressed: () => _onOpen(document),
).padded(const EdgeInsets.symmetric(horizontal: 8.0)),
],
),
),
body: NestedScrollView(
headerSliverBuilder: (context, innerBoxIsScrolled) => [
SliverAppBar(
leading: IconButton(
icon: const Icon(
Icons.arrow_back,
color: Colors
.black, //TODO: check if there is a way to dynamically determine color...
),
onPressed: () => Navigator.pop(context),
),
floating: true,
pinned: true,
expandedHeight: 200.0,
flexibleSpace: DocumentPreview(
id: document.id,
fit: BoxFit.cover,
),
bottom: ColoredTabBar(
backgroundColor: Theme.of(context).colorScheme.primaryContainer,
tabBar: TabBar(
tabs: [
Tab(
child: Text(
S.of(context).documentDetailsPageTabOverviewLabel,
style: TextStyle(
color: Theme.of(context).colorScheme.onPrimaryContainer),
),
),
Tab(
child: Text(
S.of(context).documentDetailsPageTabContentLabel,
style: TextStyle(
color: Theme.of(context).colorScheme.onPrimaryContainer),
),
),
Tab(
child: Text(
S.of(context).documentDetailsPageTabMetaDataLabel,
style: TextStyle(
color: Theme.of(context).colorScheme.onPrimaryContainer),
),
),
],
),
),
),
],
body: TabBarView(
children: [
_buildDocumentOverview(document, state.filter.titleAndContentMatchString),
_buildDocumentContentView(document, state.filter.titleAndContentMatchString),
_buildDocumentMetaDataView(document),
].padded(),
),
),
),
),
);
},
);
}
Widget _buildDocumentMetaDataView(DocumentModel document) {
return FutureBuilder<DocumentMetaData>(
future: getIt<DocumentRepository>().getMetaData(document),
builder: (context, snapshot) {
if (!snapshot.hasData) {
return const Center(child: CircularProgressIndicator());
}
final meta = snapshot.data!;
return ListView(
children: [
_DetailsItem.text(_detailedDateFormat.format(document.modified),
label: S.of(context).documentModifiedPropertyLabel, context: context),
_separator(),
_DetailsItem.text(_detailedDateFormat.format(document.added),
label: S.of(context).documentAddedPropertyLabel, context: context),
_separator(),
_DetailsItem(
label: S.of(context).documentArchiveSerialNumberPropertyLongLabel,
content: document.archiveSerialNumber != null
? Text(document.archiveSerialNumber.toString())
: OutlinedButton(
child: Text(S.of(context).documentDetailsPageAssignAsnButtonLabel),
onPressed: () => BlocProvider.of<DocumentsCubit>(context).assignAsn(document),
),
),
_separator(),
_DetailsItem.text(
meta.mediaFilename,
context: context,
label: S.of(context).documentMetaDataMediaFilenamePropertyLabel,
),
_separator(),
_DetailsItem.text(
meta.originalChecksum,
context: context,
label: S.of(context).documentMetaDataChecksumLabel,
),
_separator(),
_DetailsItem.text(formatBytes(meta.originalSize, 2),
label: S.of(context).documentMetaDataOriginalFileSizeLabel, context: context),
_separator(),
_DetailsItem.text(
meta.originalMimeType,
label: S.of(context).documentMetaDataOriginalMimeTypeLabel,
context: context,
),
_separator(),
],
);
},
);
}
Widget _buildDocumentContentView(DocumentModel document, String? match) {
return SingleChildScrollView(
child: _DetailsItem(
content: HighlightedText(
text: document.content ?? "",
highlights: match == null ? [] : match.split(" "),
style: Theme.of(context).textTheme.bodyText2,
caseSensitive: false,
),
label: S.of(context).documentDetailsPageTabContentLabel,
),
);
}
Widget _buildDocumentOverview(DocumentModel document, String? match) {
return ListView(
children: [
_DetailsItem(
content: HighlightedText(
text: document.title,
highlights: match?.split(" ") ?? <String>[],
),
label: S.of(context).documentTitlePropertyLabel,
),
_separator(),
_DetailsItem.text(
DateFormat.yMMMd(Localizations.localeOf(context).toLanguageTag())
.format(document.created),
context: context,
label: S.of(context).documentCreatedPropertyLabel,
),
_separator(),
_DetailsItem(
content: DocumentTypeWidget(
documentTypeId: document.documentType,
afterSelected: () {
Navigator.pop(context);
},
),
label: S.of(context).documentDocumentTypePropertyLabel,
),
_separator(),
_DetailsItem(
label: S.of(context).documentCorrespondentPropertyLabel,
content: CorrespondentWidget(
correspondentId: document.correspondent,
afterSelected: () {
Navigator.pop(context);
},
),
),
_separator(),
_DetailsItem(
label: S.of(context).documentStoragePathPropertyLabel,
content: StoragePathWidget(
pathId: document.storagePath,
afterSelected: () {
Navigator.pop(context);
},
),
),
_separator(),
_DetailsItem(
label: S.of(context).documentTagsPropertyLabel,
content: Padding(
padding: const EdgeInsets.only(top: 8.0),
child: TagsWidget(
tagIds: document.tags,
),
),
),
// _separator(),
// FutureBuilder<List<SimilarDocumentModel>>(
// future: getIt<DocumentRepository>().findSimilar(document.id),
// builder: (context, snapshot) {
// if (!snapshot.hasData) {
// return CircularProgressIndicator();
// }
// return ExpansionTile(
// tilePadding: const EdgeInsets.symmetric(horizontal: 8.0),
// title: Text(
// S.of(context).documentDetailsPageSimilarDocumentsLabel,
// style:
// Theme.of(context).textTheme.headline5?.copyWith(fontWeight: FontWeight.bold),
// ),
// children: snapshot.data!
// .map((e) => DocumentListItem(
// document: e,
// onTap: (doc) {},
// isSelected: false,
// isAtLeastOneSelected: false))
// .toList(),
// );
// }),
],
);
}
Widget _separator() {
return const SizedBox(height: 32.0);
}
void _onEdit(DocumentModel document) {
Navigator.push(
context,
MaterialPageRoute(
builder: (_) => LabelBlocProvider(
child: DocumentEditPage(document: document),
),
maintainState: true,
),
);
}
Future<void> _onDownload(DocumentModel document) async {
setState(() {
_isDownloadPending = true;
});
getIt<DocumentRepository>().download(document).then((bytes) async {
//FIXME: logic currently flawed, some error somewhere but cannot look into directory...
final dir = await getApplicationDocumentsDirectory();
final dirPath = dir.path + "/files/";
var filePath = dirPath + document.originalFileName;
if (File(filePath).existsSync()) {
final count = dir
.listSync()
.where((entity) => (entity.path.contains(document.originalFileName)))
.fold<int>(0, (previous, element) => previous + 1);
final extSeperationIdx = filePath.lastIndexOf(".");
filePath =
filePath.replaceRange(extSeperationIdx, extSeperationIdx + 1, " (${count + 1}).");
}
Directory(dirPath).createSync();
await File(filePath).writeAsBytes(bytes);
_isDownloadPending = false;
showSnackBar(context, "Document successfully downloaded to $filePath"); //TODO: INTL
});
}
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);
}).onError<ErrorMessage>((error, _) {
showSnackBar(context, translateError(context, error.code));
});
}
});
}
Future<void> _onOpen(DocumentModel document) async {
Navigator.of(context).push(
MaterialPageRoute(
builder: (context) => DocumentView(document: document),
),
);
}
static String formatBytes(int bytes, int decimals) {
if (bytes <= 0) return "0 B";
const suffixes = ["B", "KB", "MB", "GB", "TB", "PB", "EB", "ZB", "YB"];
var i = (log(bytes) / log(1024)).floor();
return ((bytes / pow(1024, i)).toStringAsFixed(decimals)) + ' ' + suffixes[i];
}
}
class _DetailsItem extends StatelessWidget {
final String label;
final Widget content;
const _DetailsItem({Key? key, required this.label, required this.content}) : super(key: key);
@override
Widget build(BuildContext context) {
return Padding(
padding: const EdgeInsets.symmetric(horizontal: 8.0),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
label,
style: Theme.of(context).textTheme.headline5?.copyWith(fontWeight: FontWeight.bold),
),
content,
],
),
);
}
_DetailsItem.text(
String text, {
required this.label,
required BuildContext context,
}) : content = Text(text, style: Theme.of(context).textTheme.bodyText2);
}
class ColoredTabBar extends Container implements PreferredSizeWidget {
ColoredTabBar({
super.key,
required this.backgroundColor,
required this.tabBar,
});
final TabBar tabBar;
final Color backgroundColor;
@override
Size get preferredSize => tabBar.preferredSize;
@override
Widget build(BuildContext context) => Container(
color: backgroundColor,
child: tabBar,
);
}

View File

@@ -0,0 +1,204 @@
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:flutter_paperless_mobile/di_initializer.dart';
import 'package:flutter_paperless_mobile/extensions/flutter_extensions.dart';
import 'package:flutter_paperless_mobile/features/documents/model/query_parameters/correspondent_query.dart';
import 'package:flutter_paperless_mobile/features/documents/model/query_parameters/document_type_query.dart';
import 'package:flutter_paperless_mobile/features/documents/model/query_parameters/id_query_parameter.dart';
import 'package:flutter_paperless_mobile/features/documents/model/query_parameters/ids_query_parameter.dart';
import 'package:flutter_paperless_mobile/features/documents/model/query_parameters/storage_path_query.dart';
import 'package:flutter_paperless_mobile/features/documents/model/query_parameters/tags_query.dart';
import 'package:flutter_paperless_mobile/features/labels/correspondent/bloc/correspondents_cubit.dart';
import 'package:flutter_paperless_mobile/features/labels/document_type/bloc/document_type_cubit.dart';
import 'package:flutter_paperless_mobile/features/documents/bloc/documents_cubit.dart';
import 'package:flutter_paperless_mobile/features/documents/model/document.model.dart';
import 'package:flutter_paperless_mobile/features/documents/repository/document_repository.dart';
import 'package:flutter_paperless_mobile/features/labels/correspondent/model/correspondent.model.dart';
import 'package:flutter_paperless_mobile/features/labels/correspondent/view/pages/add_correspondent_page.dart';
import 'package:flutter_paperless_mobile/features/labels/document_type/model/document_type.model.dart';
import 'package:flutter_paperless_mobile/features/labels/document_type/view/pages/add_document_type_page.dart';
import 'package:flutter_paperless_mobile/features/labels/storage_path/bloc/storage_path_cubit.dart';
import 'package:flutter_paperless_mobile/features/labels/storage_path/model/storage_path.model.dart';
import 'package:flutter_paperless_mobile/features/labels/storage_path/view/pages/add_storage_path_page.dart';
import 'package:flutter_paperless_mobile/features/labels/tags/view/widgets/tags_form_field.dart';
import 'package:flutter_paperless_mobile/features/labels/view/widgets/label_form_field.dart';
import 'package:flutter_paperless_mobile/generated/l10n.dart';
import 'package:flutter_paperless_mobile/util.dart';
import 'package:form_builder_validators/form_builder_validators.dart';
import 'package:image/image.dart';
import 'package:intl/intl.dart';
class DocumentEditPage extends StatefulWidget {
final DocumentModel document;
const DocumentEditPage({Key? key, required this.document}) : super(key: key);
@override
State<DocumentEditPage> createState() => _DocumentEditPageState();
}
class _DocumentEditPageState extends State<DocumentEditPage> {
static const fkTitle = "title";
static const fkCorrespondent = "correspondent";
static const fkTags = "tags";
static const fkDocumentType = "documentType";
static const fkCreatedDate = "createdAtDate";
static const fkStoragePath = 'storagePath';
late Future<Uint8List> documentBytes;
final GlobalKey<FormBuilderState> _formKey = GlobalKey();
bool _isSubmitLoading = false;
@override
void initState() {
documentBytes = getIt<DocumentRepository>().getPreview(widget.document.id);
super.initState();
}
@override
Widget build(BuildContext context) {
return Scaffold(
resizeToAvoidBottomInset: false,
floatingActionButton: FloatingActionButton.extended(
onPressed: () async {
if (_formKey.currentState?.saveAndValidate() ?? false) {
final values = _formKey.currentState!.value;
final updatedDocument = widget.document.copyWith(
title: values[fkTitle],
created: values[fkCreatedDate],
documentType: values[fkDocumentType] as IdQueryParameter,
correspondent: values[fkCorrespondent] as IdQueryParameter,
storagePath: values[fkStoragePath] as IdQueryParameter,
tags: values[fkTags] as IdsQueryParameter,
);
setState(() {
_isSubmitLoading = true;
});
await getIt<DocumentsCubit>().updateDocument(updatedDocument);
Navigator.pop(context);
showSnackBar(context, "Document successfully updated."); //TODO: INTL
}
},
icon: const Icon(Icons.save),
label: Text(S.of(context).genericActionSaveLabel),
),
appBar: AppBar(
title: Text(S.of(context).documentEditPageTitle),
bottom: _isSubmitLoading
? const PreferredSize(
preferredSize: Size.fromHeight(4),
child: LinearProgressIndicator(),
)
: null,
),
extendBody: true,
body: Padding(
padding: EdgeInsets.only(
bottom: MediaQuery.of(context).viewInsets.bottom,
top: 8,
left: 8,
right: 8,
),
child: FormBuilder(
key: _formKey,
child: ListView(children: [
_buildTitleFormField().padded(),
_buildCreatedAtFormField().padded(),
BlocBuilder<DocumentTypeCubit, Map<int, DocumentType>>(
builder: (context, state) {
return LabelFormField<DocumentType, DocumentTypeQuery>(
notAssignedSelectable: false,
formBuilderState: _formKey.currentState,
labelCreationWidgetBuilder: (currentInput) => BlocProvider.value(
value: BlocProvider.of<DocumentTypeCubit>(context),
child: AddDocumentTypePage(
initialName: currentInput,
),
),
label: S.of(context).documentDocumentTypePropertyLabel,
initialValue: DocumentTypeQuery.fromId(widget.document.documentType),
state: state,
name: fkDocumentType,
queryParameterIdBuilder: DocumentTypeQuery.fromId,
queryParameterNotAssignedBuilder: DocumentTypeQuery.notAssigned,
prefixIcon: const Icon(Icons.description_outlined),
);
},
).padded(),
BlocBuilder<CorrespondentCubit, Map<int, Correspondent>>(
builder: (context, state) {
return LabelFormField<Correspondent, CorrespondentQuery>(
notAssignedSelectable: false,
formBuilderState: _formKey.currentState,
labelCreationWidgetBuilder: (initialValue) => BlocProvider.value(
value: BlocProvider.of<CorrespondentCubit>(context),
child: AddCorrespondentPage(initalValue: initialValue),
),
label: S.of(context).documentCorrespondentPropertyLabel,
state: state,
initialValue: CorrespondentQuery.fromId(widget.document.correspondent),
name: fkCorrespondent,
queryParameterIdBuilder: CorrespondentQuery.fromId,
queryParameterNotAssignedBuilder: CorrespondentQuery.notAssigned,
prefixIcon: const Icon(Icons.person_outlined),
);
},
).padded(),
BlocBuilder<StoragePathCubit, Map<int, StoragePath>>(
builder: (context, state) {
return LabelFormField<StoragePath, StoragePathQuery>(
notAssignedSelectable: false,
formBuilderState: _formKey.currentState,
labelCreationWidgetBuilder: (initialValue) => BlocProvider.value(
value: BlocProvider.of<StoragePathCubit>(context),
child: AddStoragePathPage(initalValue: initialValue),
),
label: S.of(context).documentStoragePathPropertyLabel,
state: state,
initialValue: StoragePathQuery.fromId(widget.document.storagePath),
name: fkStoragePath,
queryParameterIdBuilder: StoragePathQuery.fromId,
queryParameterNotAssignedBuilder: StoragePathQuery.notAssigned,
prefixIcon: const Icon(Icons.folder_outlined),
);
},
).padded(),
TagFormField(
initialValue: TagsQuery.fromIds(widget.document.tags),
name: fkTags,
).padded(),
]),
),
),
);
}
Widget _buildTitleFormField() {
return FormBuilderTextField(
name: fkTitle,
validator: FormBuilderValidators.required(),
decoration: InputDecoration(
label: Text(S.of(context).documentTitlePropertyLabel),
),
initialValue: widget.document.title,
);
}
Widget _buildCreatedAtFormField() {
return FormBuilderDateTimePicker(
inputType: InputType.date,
name: fkCreatedDate,
decoration: InputDecoration(
prefixIcon: const Icon(Icons.calendar_month_outlined),
label: Text(S.of(context).documentCreatedPropertyLabel),
),
initialValue: widget.document.created,
format: DateFormat("dd. MMMM yyyy"), //TODO: Localized date format
initialEntryMode: DatePickerEntryMode.calendar,
);
}
}

View File

@@ -0,0 +1,50 @@
import 'package:flutter/material.dart';
import 'package:flutter_paperless_mobile/di_initializer.dart';
import 'package:flutter_paperless_mobile/features/documents/model/document.model.dart';
import 'package:flutter_paperless_mobile/features/documents/repository/document_repository.dart';
import 'package:flutter_paperless_mobile/generated/l10n.dart';
import 'package:pdfx/pdfx.dart';
class DocumentView extends StatefulWidget {
final DocumentModel document;
const DocumentView({
Key? key,
required this.document,
}) : super(key: key);
@override
State<DocumentView> createState() => _DocumentViewState();
}
class _DocumentViewState extends State<DocumentView> {
late PdfController _pdfController;
@override
void initState() {
super.initState();
_pdfController = PdfController(
document: PdfDocument.openData(
getIt<DocumentRepository>().getPreview(widget.document.id),
),
);
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: Text(S.of(context).documentPreviewPageTitle),
),
body: PdfView(
builders: PdfViewBuilders<DefaultBuilderOptions>(
options: const DefaultBuilderOptions(),
pageLoaderBuilder: (context) => const Center(
child: CircularProgressIndicator(),
),
),
controller: _pdfController,
),
);
}
}

View File

@@ -0,0 +1,239 @@
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:flutter_paperless_mobile/core/bloc/connectivity_cubit.dart';
import 'package:flutter_paperless_mobile/core/logic/error_code_localization_mapper.dart';
import 'package:flutter_paperless_mobile/core/model/error_message.dart';
import 'package:flutter_paperless_mobile/core/widgets/offline_banner.dart';
import 'package:flutter_paperless_mobile/di_initializer.dart';
import 'package:flutter_paperless_mobile/features/labels/correspondent/bloc/correspondents_cubit.dart';
import 'package:flutter_paperless_mobile/features/labels/document_type/bloc/document_type_cubit.dart';
import 'package:flutter_paperless_mobile/features/documents/bloc/documents_cubit.dart';
import 'package:flutter_paperless_mobile/features/documents/bloc/documents_state.dart';
import 'package:flutter_paperless_mobile/features/documents/model/document.model.dart';
import 'package:flutter_paperless_mobile/features/documents/view/pages/document_details_page.dart';
import 'package:flutter_paperless_mobile/features/documents/view/widgets/documents_empty_state.dart';
import 'package:flutter_paperless_mobile/features/documents/view/widgets/grid/document_grid.dart';
import 'package:flutter_paperless_mobile/features/documents/view/widgets/list/document_list.dart';
import 'package:flutter_paperless_mobile/features/documents/view/widgets/search/document_filter_panel.dart';
import 'package:flutter_paperless_mobile/features/documents/view/widgets/selection/documents_page_app_bar.dart';
import 'package:flutter_paperless_mobile/features/documents/view/widgets/sort_documents_button.dart';
import 'package:flutter_paperless_mobile/features/home/view/widget/info_drawer.dart';
import 'package:flutter_paperless_mobile/features/labels/storage_path/bloc/storage_path_cubit.dart';
import 'package:flutter_paperless_mobile/features/login/bloc/authentication_cubit.dart';
import 'package:flutter_paperless_mobile/features/labels/tags/bloc/tags_cubit.dart';
import 'package:flutter_paperless_mobile/util.dart';
import 'package:infinite_scroll_pagination/infinite_scroll_pagination.dart';
import 'package:sliding_up_panel/sliding_up_panel.dart';
class DocumentsPage extends StatefulWidget {
const DocumentsPage({Key? key}) : super(key: key);
@override
State<DocumentsPage> createState() => _DocumentsPageState();
}
class _DocumentsPageState extends State<DocumentsPage> {
final PagingController<int, DocumentModel> _pagingController =
PagingController<int, DocumentModel>(
firstPageKey: 1,
);
final PanelController _panelController = PanelController();
ViewType _viewType = ViewType.list;
@override
void initState() {
super.initState();
final documentsCubit = BlocProvider.of<DocumentsCubit>(context);
if (!documentsCubit.state.isLoaded) {
documentsCubit.loadDocuments().onError<ErrorMessage>(
(error, stackTrace) => showSnackBar(
context,
translateError(context, error.code),
),
);
}
_pagingController.addPageRequestListener(_loadNewPage);
}
@override
void dispose() {
_pagingController.dispose();
super.dispose();
}
Future<void> _loadNewPage(int pageKey) async {
final documentsCubit = BlocProvider.of<DocumentsCubit>(context);
final pageCount =
documentsCubit.state.inferPageCount(pageSize: documentsCubit.state.filter.pageSize);
if (pageCount <= pageKey + 1) {
_pagingController.nextPageKey = null;
}
documentsCubit.loadMore();
}
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))
.onError<ErrorMessage>((error, _) {
showSnackBar(context, translateError(context, error.code));
});
}
@override
Widget build(BuildContext context) {
return WillPopScope(
onWillPop: () async {
if (_panelController.isPanelOpen) {
FocusScope.of(context).unfocus();
_panelController.close();
return false;
}
final docBloc = BlocProvider.of<DocumentsCubit>(context);
if (docBloc.state.selection.isNotEmpty) {
docBloc.resetSelection();
return false;
}
return true;
},
child: BlocConsumer<ConnectivityCubit, ConnectivityState>(
listenWhen: (previous, current) =>
previous != ConnectivityState.connected && current == ConnectivityState.connected,
listener: (context, state) {
BlocProvider.of<DocumentsCubit>(context).loadDocuments();
},
builder: (context, connectivityState) {
return Scaffold(
drawer: BlocProvider.value(
value: BlocProvider.of<AuthenticationCubit>(context),
child: const InfoDrawer(),
),
resizeToAvoidBottomInset: true,
appBar: connectivityState == ConnectivityState.connected ? null : const OfflineBanner(),
body: SlidingUpPanel(
backdropEnabled: true,
parallaxEnabled: true,
parallaxOffset: .5,
controller: _panelController,
defaultPanelState: PanelState.CLOSED,
minHeight: 48,
maxHeight: MediaQuery.of(context).size.height -
kBottomNavigationBarHeight -
2 * kToolbarHeight,
borderRadius: const BorderRadius.only(
topLeft: Radius.circular(16),
topRight: Radius.circular(16),
),
body: _buildBody(connectivityState),
color: Theme.of(context).scaffoldBackgroundColor,
panelBuilder: (scrollController) => DocumentFilterPanel(
panelController: _panelController,
scrollController: scrollController,
),
),
);
},
),
);
}
Widget _buildBody(ConnectivityState connectivityState) {
return BlocBuilder<DocumentsCubit, DocumentsState>(
builder: (context, state) {
// Some ugly tricks to make it work with bloc, update pageController
_pagingController.value = PagingState(
itemList: state.documents,
nextPageKey: state.nextPageNumber,
);
late Widget child;
switch (_viewType) {
case ViewType.list:
child = DocumentListView(
onTap: _openDocumentDetails,
state: state,
onSelected: _onSelected,
pagingController: _pagingController,
hasInternetConnection: connectivityState == ConnectivityState.connected,
);
break;
case ViewType.grid:
child = DocumentGridView(
onTap: _openDocumentDetails,
state: state,
onSelected: _onSelected,
pagingController: _pagingController,
hasInternetConnection: connectivityState == ConnectivityState.connected);
break;
}
if (state.isLoaded && state.documents.isEmpty) {
child = SliverToBoxAdapter(
child: DocumentsEmptyState(
state: state,
),
);
}
return RefreshIndicator(
onRefresh: _onRefresh,
child: Container(
padding: const EdgeInsets.only(
bottom: 142,
), // Prevents panel from hiding scrollable content
child: CustomScrollView(
slivers: [
DocumentsPageAppBar(
actions: [
const SortDocumentsButton(),
IconButton(
icon: Icon(
_viewType == ViewType.grid ? Icons.list : Icons.grid_view,
),
onPressed: () => setState(() => _viewType = _viewType.toggle()),
),
],
),
child
],
),
),
);
},
);
}
void _openDocumentDetails(DocumentModel model) {
Navigator.push(
context,
MaterialPageRoute(
builder: (context) => MultiBlocProvider(
providers: [
BlocProvider.value(value: getIt<DocumentsCubit>()),
BlocProvider.value(value: getIt<CorrespondentCubit>()),
BlocProvider.value(value: getIt<DocumentTypeCubit>()),
BlocProvider.value(value: getIt<TagCubit>()),
BlocProvider.value(value: getIt<StoragePathCubit>()),
],
child: DocumentDetailsPage(
documentId: model.id,
),
),
),
);
}
}
enum ViewType {
grid,
list;
ViewType toggle() {
return this == grid ? list : grid;
}
}

View File

@@ -0,0 +1,49 @@
import 'package:flutter/material.dart';
import 'package:flutter_paperless_mobile/features/documents/model/document.model.dart';
import 'package:flutter_paperless_mobile/generated/l10n.dart';
class DeleteDocumentConfirmationDialog extends StatelessWidget {
final DocumentModel document;
const DeleteDocumentConfirmationDialog({super.key, required this.document});
@override
Widget build(BuildContext context) {
return AlertDialog(
title: Text(S.of(context).documentsPageSelectionBulkDeleteDialogTitle),
content: Column(
mainAxisSize: MainAxisSize.min,
children: [
Text(
S.of(context).documentsPageSelectionBulkDeleteDialogWarningTextOne,
),
const SizedBox(height: 16),
Text(
document.title,
maxLines: 2,
overflow: TextOverflow.ellipsis,
style: const TextStyle(
fontWeight: FontWeight.w700,
),
),
const SizedBox(height: 16),
Text(S.of(context).documentsPageSelectionBulkDeleteDialogContinueText),
],
),
actions: [
TextButton(
onPressed: () => Navigator.pop(context, false),
child: Text(S.of(context).genericActionCancelLabel),
),
TextButton(
style: ButtonStyle(
foregroundColor: MaterialStateProperty.all(Theme.of(context).colorScheme.error),
),
onPressed: () {
Navigator.pop(context, true);
},
child: Text(S.of(context).genericActionDeleteLabel),
),
],
);
}
}

View File

@@ -0,0 +1,45 @@
import 'package:cached_network_image/cached_network_image.dart';
import 'package:flutter/material.dart';
import 'package:flutter_cache_manager/flutter_cache_manager.dart';
import 'package:flutter_paperless_mobile/di_initializer.dart';
import 'package:flutter_paperless_mobile/features/documents/repository/document_repository.dart';
import 'package:shimmer/shimmer.dart';
class DocumentPreview extends StatelessWidget {
final int id;
final BoxFit fit;
final Alignment alignment;
final double borderRadius;
const DocumentPreview({
Key? key,
required this.id,
this.fit = BoxFit.cover,
this.alignment = Alignment.center,
this.borderRadius = 8.0,
}) : super(key: key);
@override
Widget build(BuildContext context) {
return
// Hero(
// tag: "document_$id",child:
ClipRRect(
borderRadius: BorderRadius.circular(borderRadius),
child: CachedNetworkImage(
fit: fit,
alignment: Alignment.topCenter,
cacheKey: "thumb_$id",
imageUrl: getIt<DocumentRepository>().getThumbnailUrl(id),
errorWidget: (ctxt, msg, __) => Text(msg),
placeholder: (context, value) => Shimmer.fromColors(
baseColor: Colors.grey[300]!,
highlightColor: Colors.grey[100]!,
child: const SizedBox(height: 100, width: 100),
),
cacheManager: getIt<CacheManager>(),
),
// ),
);
}
}

View File

@@ -0,0 +1,38 @@
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:flutter_paperless_mobile/core/widgets/empty_state.dart';
import 'package:flutter_paperless_mobile/extensions/flutter_extensions.dart';
import 'package:flutter_paperless_mobile/features/documents/bloc/documents_cubit.dart';
import 'package:flutter_paperless_mobile/features/documents/bloc/documents_state.dart';
import 'package:flutter_paperless_mobile/features/documents/bloc/saved_view_cubit.dart';
import 'package:flutter_paperless_mobile/features/documents/model/document_filter.dart';
import 'package:flutter_paperless_mobile/generated/l10n.dart';
class DocumentsEmptyState extends StatelessWidget {
final DocumentsState state;
const DocumentsEmptyState({
Key? key,
required this.state,
}) : super(key: key);
@override
Widget build(BuildContext context) {
return Center(
child: EmptyState(
title: S.of(context).documentsPageEmptyStateOopsText,
subtitle: S.of(context).documentsPageEmptyStateNothingHereText,
bottomChild: state.filter != DocumentFilter.initial
? ElevatedButton(
onPressed: () async {
await BlocProvider.of<DocumentsCubit>(context).updateFilter();
BlocProvider.of<SavedViewCubit>(context).resetSelection();
},
child: Text(
S.of(context).documentsFilterPageResetFilterLabel,
),
).padded()
: null,
),
);
}
}

View File

@@ -0,0 +1,48 @@
import 'package:flutter/material.dart';
import 'package:flutter_paperless_mobile/core/widgets/documents_list_loading_widget.dart';
import 'package:flutter_paperless_mobile/features/documents/bloc/documents_state.dart';
import 'package:flutter_paperless_mobile/features/documents/model/document.model.dart';
import 'package:flutter_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;
const DocumentGridView({
super.key,
required this.onTap,
required this.pagingController,
required this.state,
required this.onSelected,
required this.hasInternetConnection,
});
@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,
);
},
noItemsFoundIndicatorBuilder: (context) =>
const DocumentsListLoadingWidget(), //TODO: Replace with grid loading widget
),
);
}
}

View File

@@ -0,0 +1,87 @@
import 'package:flutter/material.dart';
import 'package:flutter_paperless_mobile/features/documents/model/document.model.dart';
import 'package:flutter_paperless_mobile/features/documents/view/widgets/document_preview.dart';
import 'package:flutter_paperless_mobile/features/labels/correspondent/view/widgets/correspondent_widget.dart';
import 'package:flutter_paperless_mobile/features/labels/document_type/view/widgets/document_type_widget.dart';
import 'package:flutter_paperless_mobile/features/labels/tags/view/widgets/tags_widget.dart';
import 'package:intl/intl.dart';
class DocumentGridItem extends StatelessWidget {
final DocumentModel document;
final bool isSelected;
final void Function(DocumentModel) onTap;
final void Function(DocumentModel) onSelected;
final bool isAtLeastOneSelected;
const DocumentGridItem({
Key? key,
required this.document,
required this.onTap,
required this.onSelected,
required this.isSelected,
required this.isAtLeastOneSelected,
}) : super(key: key);
@override
Widget build(BuildContext context) {
return GestureDetector(
onTap: _onTap,
onLongPress: () => onSelected(document),
child: AbsorbPointer(
absorbing: isAtLeastOneSelected,
child: Padding(
padding: const EdgeInsets.all(8.0),
child: Card(
elevation: 1.0,
color: isSelected
? Theme.of(context).colorScheme.inversePrimary
: Theme.of(context).cardColor,
child: Column(
children: [
AspectRatio(
aspectRatio: 1,
child: DocumentPreview(
id: document.id,
borderRadius: 12.0,
),
),
Expanded(
child: Padding(
padding: const EdgeInsets.all(8.0),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
CorrespondentWidget(correspondentId: document.correspondent),
DocumentTypeWidget(documentTypeId: document.documentType),
Text(
document.title,
maxLines: document.tags.isEmpty ? 3 : 2,
overflow: TextOverflow.ellipsis,
style: Theme.of(context).textTheme.titleMedium,
),
const Spacer(),
TagsWidget(
tagIds: document.tags,
isMultiLine: false,
),
Text(DateFormat.yMMMd(Intl.getCurrentLocale()).format(document.created)),
],
),
),
),
],
),
),
),
),
);
}
void _onTap() {
if (isAtLeastOneSelected || isSelected) {
onSelected(document);
} else {
onTap(document);
}
}
}

View File

@@ -0,0 +1,44 @@
import 'package:flutter/material.dart';
import 'package:flutter_paperless_mobile/core/widgets/documents_list_loading_widget.dart';
import 'package:flutter_paperless_mobile/core/widgets/offline_widget.dart';
import 'package:flutter_paperless_mobile/features/documents/bloc/documents_state.dart';
import 'package:flutter_paperless_mobile/features/documents/model/document.model.dart';
import 'package:flutter_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 model) onTap;
final void Function(DocumentModel) onSelected;
final PagingController<int, DocumentModel> pagingController;
final DocumentsState state;
final bool hasInternetConnection;
const DocumentListView({
super.key,
required this.onTap,
required this.pagingController,
required this.state,
required this.onSelected,
required this.hasInternetConnection,
});
@override
Widget build(BuildContext context) {
return PagedSliverList<int, DocumentModel>(
pagingController: pagingController,
builderDelegate: PagedChildBuilderDelegate(
animateTransitions: true,
itemBuilder: (context, item, index) {
return DocumentListItem(
document: item,
onTap: onTap,
isSelected: state.selection.contains(item),
onSelected: onSelected,
isAtLeastOneSelected: state.selection.isNotEmpty,
);
},
noItemsFoundIndicatorBuilder: (context) =>
hasInternetConnection ? const DocumentsListLoadingWidget() : const OfflineWidget(),
),
);
}
}

View File

@@ -0,0 +1,88 @@
import 'package:flutter/material.dart';
import 'package:flutter_paperless_mobile/features/documents/model/document.model.dart';
import 'package:flutter_paperless_mobile/features/documents/view/widgets/document_preview.dart';
import 'package:flutter_paperless_mobile/features/labels/correspondent/view/widgets/correspondent_widget.dart';
import 'package:flutter_paperless_mobile/features/labels/tags/view/widgets/tags_widget.dart';
class DocumentListItem extends StatelessWidget {
static const a4AspectRatio = 1 / 1.4142;
final DocumentModel document;
final bool isSelected;
final void Function(DocumentModel) onTap;
final void Function(DocumentModel)? onSelected;
final bool isAtLeastOneSelected;
const DocumentListItem({
Key? key,
required this.document,
required this.onTap,
this.onSelected,
required this.isSelected,
required this.isAtLeastOneSelected,
}) : super(key: key);
@override
Widget build(BuildContext context) {
return SizedBox(
child: ListTile(
dense: true,
selected: isSelected,
onTap: () => _onTap(),
selectedTileColor: Theme.of(context).colorScheme.inversePrimary,
onLongPress: () => onSelected?.call(document),
title: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisAlignment: MainAxisAlignment.spaceAround,
children: [
Row(
children: [
AbsorbPointer(
absorbing: isAtLeastOneSelected,
child: CorrespondentWidget(
correspondentId: document.correspondent,
afterSelected: () {},
),
),
],
),
Text(
document.title,
overflow: TextOverflow.ellipsis,
maxLines: document.tags.isEmpty ? 2 : 1,
),
],
),
subtitle: Padding(
padding: const EdgeInsets.symmetric(vertical: 4),
child: AbsorbPointer(
absorbing: isAtLeastOneSelected,
child: TagsWidget(
tagIds: document.tags,
isMultiLine: false,
),
),
),
isThreeLine: document.tags.isNotEmpty,
leading: AspectRatio(
aspectRatio: a4AspectRatio,
child: GestureDetector(
child: DocumentPreview(
id: document.id,
fit: BoxFit.cover,
alignment: Alignment.topCenter,
),
),
),
contentPadding: const EdgeInsets.all(8.0),
),
);
}
void _onTap() {
if (isAtLeastOneSelected || isSelected) {
onSelected?.call(document);
} else {
onTap(document);
}
}
}

View File

@@ -0,0 +1,21 @@
import 'package:flutter/material.dart';
import 'package:flutter_form_builder/flutter_form_builder.dart';
import 'package:flutter_paperless_mobile/features/documents/model/query_parameters/sort_field.dart';
class OrderByDropdown extends StatefulWidget {
static const fkOrderBy = "orderBy";
const OrderByDropdown({super.key});
@override
State<OrderByDropdown> createState() => _OrderByDropdownState();
}
class _OrderByDropdownState extends State<OrderByDropdown> {
@override
Widget build(BuildContext context) {
return FormBuilderDropdown<SortField>(
name: OrderByDropdown.fkOrderBy,
items: const [],
);
}
}

View File

@@ -0,0 +1,530 @@
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:flutter_form_builder/flutter_form_builder.dart';
import 'package:flutter_paperless_mobile/extensions/flutter_extensions.dart';
import 'package:flutter_paperless_mobile/features/documents/bloc/saved_view_cubit.dart';
import 'package:flutter_paperless_mobile/features/documents/model/document.model.dart';
import 'package:flutter_paperless_mobile/features/documents/model/query_parameters/correspondent_query.dart';
import 'package:flutter_paperless_mobile/features/documents/model/query_parameters/document_type_query.dart';
import 'package:flutter_paperless_mobile/features/documents/model/query_parameters/sort_field.dart';
import 'package:flutter_paperless_mobile/features/documents/model/query_parameters/query_type.dart';
import 'package:flutter_paperless_mobile/features/documents/model/query_parameters/storage_path_query.dart';
import 'package:flutter_paperless_mobile/features/documents/model/query_parameters/tags_query.dart';
import 'package:flutter_paperless_mobile/features/labels/correspondent/bloc/correspondents_cubit.dart';
import 'package:flutter_paperless_mobile/features/labels/document_type/bloc/document_type_cubit.dart';
import 'package:flutter_paperless_mobile/features/documents/bloc/documents_cubit.dart';
import 'package:flutter_paperless_mobile/features/documents/bloc/documents_state.dart';
import 'package:flutter_paperless_mobile/features/documents/model/document_filter.dart';
import 'package:flutter_paperless_mobile/features/documents/view/widgets/search/query_type_form_field.dart';
import 'package:flutter_paperless_mobile/features/labels/correspondent/model/correspondent.model.dart';
import 'package:flutter_paperless_mobile/features/labels/document_type/model/document_type.model.dart';
import 'package:flutter_paperless_mobile/features/labels/storage_path/bloc/storage_path_cubit.dart';
import 'package:flutter_paperless_mobile/features/labels/storage_path/model/storage_path.model.dart';
import 'package:flutter_paperless_mobile/features/labels/tags/view/widgets/tags_form_field.dart';
import 'package:flutter_paperless_mobile/features/labels/view/widgets/label_form_field.dart';
import 'package:flutter_paperless_mobile/features/scan/view/document_upload_page.dart';
import 'package:flutter_paperless_mobile/generated/l10n.dart';
import 'package:intl/intl.dart';
import 'package:sliding_up_panel/sliding_up_panel.dart';
enum DateRangeSelection { before, after }
class DocumentFilterPanel extends StatefulWidget {
final PanelController panelController;
final ScrollController scrollController;
const DocumentFilterPanel({
Key? key,
required this.panelController,
required this.scrollController,
}) : super(key: key);
@override
State<DocumentFilterPanel> createState() => _DocumentFilterPanelState();
}
class _DocumentFilterPanelState extends State<DocumentFilterPanel> {
static const fkCorrespondent = DocumentModel.correspondentKey;
static const fkDocumentType = DocumentModel.documentTypeKey;
static const fkStoragePath = DocumentModel.storagePathKey;
static const fkQuery = "query";
static const fkCreatedAt = DocumentModel.createdKey;
static const fkAddedAt = DocumentModel.addedKey;
static const _sortFields = [
SortField.created,
SortField.added,
SortField.modified,
SortField.title,
SortField.correspondentName,
SortField.documentType,
SortField.archiveSerialNumber
];
final _formKey = GlobalKey<FormBuilderState>();
bool _isQueryLoading = false;
DateTimeRange? _dateTimeRangeOfNullable(DateTime? start, DateTime? end) {
if (start == null && end == null) {
return null;
}
if (start != null && end != null) {
return DateTimeRange(start: start, end: end);
}
assert(start != null || end != null);
final singleDate = (start ?? end)!;
return DateTimeRange(start: singleDate, end: singleDate);
}
@override
Widget build(BuildContext context) {
return BlocConsumer<DocumentsCubit, DocumentsState>(
listener: (context, state) {
// Set initial values, otherwise they would not automatically update.
_patchFromFilter(state.filter);
},
builder: (context, state) {
return FormBuilder(
key: _formKey,
child: MediaQuery.removePadding(
context: context,
removeTop: true,
child: Column(
children: [
Stack(
alignment: Alignment.center,
children: [
_buildDragLine(),
Align(
alignment: Alignment.topRight,
child: TextButton.icon(
icon: const Icon(Icons.refresh),
label: Text(S.of(context).documentsFilterPageResetFilterLabel),
onPressed: () => _resetFilter(context),
),
),
],
),
const SizedBox(
height: 8.0,
),
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text(
S.of(context).documentsFilterPageTitle,
style: Theme.of(context).textTheme.titleLarge,
),
TextButton(
onPressed: _onApplyFilter,
child: Text(S.of(context).documentsFilterPageApplyFilterLabel),
),
],
).padded(),
Expanded(
child: ListView(
controller: widget.scrollController,
children: [
const SizedBox(
height: 16.0,
),
Align(
alignment: Alignment.centerLeft,
child: Text(S.of(context).documentsFilterPageSearchLabel),
).padded(),
_buildQueryFormField(state),
_buildSortByChipsList(context, state),
Align(
alignment: Alignment.centerLeft,
child: Text(S.of(context).documentsFilterPageAdvancedLabel),
).padded(),
_buildCreatedDateRangePickerFormField(state).padded(),
_buildAddedDateRangePickerFormField(state).padded(),
_buildCorrespondentFormField(state).padded(),
_buildDocumentTypeFormField(state).padded(),
_buildStoragePathFormField(state).padded(),
TagFormField(
name: DocumentModel.tagsKey,
initialValue: state.filter.tags,
).padded(),
],
),
),
],
),
),
);
},
);
}
void _resetFilter(BuildContext context) async {
FocusScope.of(context).unfocus();
await BlocProvider.of<DocumentsCubit>(context).updateFilter();
BlocProvider.of<SavedViewCubit>(context).resetSelection();
if (!widget.panelController.isPanelClosed) {
widget.panelController.close();
}
}
Widget _buildDocumentTypeFormField(DocumentsState docState) {
return BlocBuilder<DocumentTypeCubit, Map<int, DocumentType>>(
builder: (context, state) {
return LabelFormField<DocumentType, DocumentTypeQuery>(
formBuilderState: _formKey.currentState,
name: fkDocumentType,
state: state,
label: S.of(context).documentDocumentTypePropertyLabel,
initialValue: docState.filter.documentType,
queryParameterIdBuilder: DocumentTypeQuery.fromId,
queryParameterNotAssignedBuilder: DocumentTypeQuery.notAssigned,
prefixIcon: const Icon(Icons.description_outlined),
);
},
);
}
Widget _buildStoragePathFormField(DocumentsState docState) {
return BlocBuilder<StoragePathCubit, Map<int, StoragePath>>(
builder: (context, state) {
return LabelFormField<StoragePath, StoragePathQuery>(
formBuilderState: _formKey.currentState,
name: fkStoragePath,
state: state,
label: S.of(context).documentStoragePathPropertyLabel,
initialValue: docState.filter.storagePath,
queryParameterIdBuilder: StoragePathQuery.fromId,
queryParameterNotAssignedBuilder: StoragePathQuery.notAssigned,
prefixIcon: const Icon(Icons.folder_outlined),
);
},
);
}
Widget _buildQueryFormField(DocumentsState state) {
final queryType = _formKey.currentState?.getRawValue(QueryTypeFormField.fkQueryType) ??
QueryType.titleAndContent;
late String label;
switch (queryType) {
case QueryType.title:
label = S.of(context).documentsFilterPageQueryOptionsTitleLabel;
break;
case QueryType.titleAndContent:
label = S.of(context).documentsFilterPageQueryOptionsTitleAndContentLabel;
break;
case QueryType.extended:
label = S.of(context).documentsFilterPageQueryOptionsExtendedLabel;
break;
}
return FormBuilderTextField(
name: fkQuery,
textInputAction: TextInputAction.done,
decoration: InputDecoration(
prefixIcon: const Icon(Icons.search_outlined),
labelText: label,
suffixIcon: QueryTypeFormField(
initialValue: state.filter.queryType,
afterSelected: (queryType) => setState(() {}),
),
),
initialValue: state.filter.queryText,
).padded();
}
Widget _buildDateRangePickerHelper(DocumentsState state, String formFieldKey) {
return SingleChildScrollView(
scrollDirection: Axis.horizontal,
child: Row(
children: [
ActionChip(
label: Text(
S.of(context).documentsFilterPageDateRangeLastSevenDaysLabel,
),
onPressed: () {
_formKey.currentState?.fields[formFieldKey]?.didChange(
DateTimeRange(
start: DateUtils.addDaysToDate(DateTime.now(), -7),
end: DateTime.now(),
),
);
},
).padded(const EdgeInsets.only(right: 8.0)),
ActionChip(
label: Text(
S.of(context).documentsFilterPageDateRangeLastMonthLabel,
),
onPressed: () {
final now = DateTime.now();
final firstDayOfLastMonth = DateUtils.addMonthsToMonthDate(now, -1);
_formKey.currentState?.fields[formFieldKey]?.didChange(
DateTimeRange(
start: DateTime(firstDayOfLastMonth.year, firstDayOfLastMonth.month, now.day),
end: DateTime.now(),
),
);
},
).padded(const EdgeInsets.only(right: 8.0)),
ActionChip(
label: Text(
S.of(context).documentsFilterPageDateRangeLastThreeMonthsLabel,
),
onPressed: () {
final now = DateTime.now();
final firstDayOfLastMonth = DateUtils.addMonthsToMonthDate(now, -3);
_formKey.currentState?.fields[formFieldKey]?.didChange(
DateTimeRange(
start: DateTime(
firstDayOfLastMonth.year,
firstDayOfLastMonth.month,
now.day,
),
end: DateTime.now(),
),
);
},
).padded(const EdgeInsets.only(right: 8.0)),
ActionChip(
label: Text(
S.of(context).documentsFilterPageDateRangeLastYearLabel,
),
onPressed: () {
final now = DateTime.now();
final firstDayOfLastMonth = DateUtils.addMonthsToMonthDate(now, -12);
_formKey.currentState?.fields[formFieldKey]?.didChange(
DateTimeRange(
start: DateTime(
firstDayOfLastMonth.year,
firstDayOfLastMonth.month,
now.day,
),
end: DateTime.now(),
),
);
},
),
],
),
);
}
Widget _buildCorrespondentFormField(DocumentsState docState) {
return BlocBuilder<CorrespondentCubit, Map<int, Correspondent>>(
builder: (context, state) {
return LabelFormField<Correspondent, CorrespondentQuery>(
formBuilderState: _formKey.currentState,
name: fkCorrespondent,
state: state,
label: S.of(context).documentCorrespondentPropertyLabel,
initialValue: docState.filter.correspondent,
queryParameterIdBuilder: CorrespondentQuery.fromId,
queryParameterNotAssignedBuilder: CorrespondentQuery.notAssigned,
prefixIcon: const Icon(Icons.person_outline),
);
},
);
}
Widget _buildCreatedDateRangePickerFormField(DocumentsState state) {
return Column(
children: [
FormBuilderDateRangePicker(
initialValue: _dateTimeRangeOfNullable(
state.filter.createdDateAfter,
state.filter.createdDateBefore,
),
pickerBuilder: (context, child) {
return Theme(
data: ThemeData.light().copyWith(
primaryColor: Theme.of(context).primaryColor,
colorScheme: Theme.of(context).colorScheme,
buttonTheme: Theme.of(context).buttonTheme,
),
child: child!,
);
},
format: DateFormat.yMMMd(Localizations.localeOf(context).toString()),
fieldStartLabelText: S.of(context).documentsFilterPageDateRangeFieldStartLabel,
fieldEndLabelText: S.of(context).documentsFilterPageDateRangeFieldEndLabel,
firstDate: DateTime.fromMicrosecondsSinceEpoch(0),
lastDate: DateTime.now(),
name: fkCreatedAt,
decoration: InputDecoration(
prefixIcon: const Icon(Icons.calendar_month_outlined),
labelText: S.of(context).documentCreatedPropertyLabel,
suffixIcon: IconButton(
icon: const Icon(Icons.clear),
onPressed: () => _formKey.currentState?.fields[fkCreatedAt]?.didChange(null),
),
),
),
_buildDateRangePickerHelper(state, fkCreatedAt),
],
);
}
Widget _buildAddedDateRangePickerFormField(DocumentsState state) {
return Column(
children: [
FormBuilderDateRangePicker(
initialValue: _dateTimeRangeOfNullable(
state.filter.addedDateAfter,
state.filter.addedDateBefore,
),
pickerBuilder: (context, child) {
return Theme(
data: ThemeData.light().copyWith(
primaryColor: Theme.of(context).primaryColor,
colorScheme: Theme.of(context).colorScheme,
buttonTheme: Theme.of(context).buttonTheme,
),
child: child!,
);
},
format: DateFormat.yMMMd(Localizations.localeOf(context).toString()),
fieldStartLabelText: S.of(context).documentsFilterPageDateRangeFieldStartLabel,
fieldEndLabelText: S.of(context).documentsFilterPageDateRangeFieldEndLabel,
firstDate: DateTime.fromMicrosecondsSinceEpoch(0),
lastDate: DateTime.now(),
name: fkAddedAt,
decoration: InputDecoration(
prefixIcon: const Icon(Icons.calendar_month_outlined),
labelText: S.of(context).documentAddedPropertyLabel,
suffixIcon: IconButton(
icon: const Icon(Icons.clear),
onPressed: () => _formKey.currentState?.fields[fkAddedAt]?.didChange(null),
),
),
),
_buildDateRangePickerHelper(state, fkAddedAt),
],
);
}
Widget _buildDragLine() {
return Container(
width: 48,
height: 5,
decoration: BoxDecoration(
color: Colors.grey[300],
borderRadius: const BorderRadius.all(Radius.circular(12.0)),
),
);
}
Widget _buildSortByChipsList(BuildContext context, DocumentsState state) {
return Padding(
padding: const EdgeInsets.all(8.0),
child: Column(
mainAxisAlignment: MainAxisAlignment.end,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
S.of(context).documentsPageOrderByLabel,
),
SizedBox(
height: kToolbarHeight,
child: ListView.separated(
itemCount: _sortFields.length,
scrollDirection: Axis.horizontal,
separatorBuilder: (context, index) => const SizedBox(
width: 8.0,
),
itemBuilder: (context, index) =>
_buildActionChip(_sortFields[index], state.filter.sortField, context),
),
),
],
),
);
}
Widget _buildActionChip(
SortField sortField, SortField? currentlySelectedOrder, BuildContext context) {
String text;
switch (sortField) {
case SortField.archiveSerialNumber:
text = S.of(context).documentArchiveSerialNumberPropertyShortLabel;
break;
case SortField.correspondentName:
text = S.of(context).documentCorrespondentPropertyLabel;
break;
case SortField.title:
text = S.of(context).documentTitlePropertyLabel;
break;
case SortField.documentType:
text = S.of(context).documentDocumentTypePropertyLabel;
break;
case SortField.created:
text = S.of(context).documentCreatedPropertyLabel;
break;
case SortField.added:
text = S.of(context).documentAddedPropertyLabel;
break;
case SortField.modified:
text = S.of(context).documentModifiedPropertyLabel;
break;
}
final docBloc = BlocProvider.of<DocumentsCubit>(context);
return ActionChip(
label: Text(text),
avatar: currentlySelectedOrder == sortField
? const Icon(
Icons.done,
color: Colors.green,
)
: null,
onPressed: () =>
docBloc.updateFilter(filter: docBloc.state.filter.copyWith(sortField: sortField)),
);
}
void _onApplyFilter() {
setState(() => _isQueryLoading = true);
_formKey.currentState?.save();
if (_formKey.currentState?.validate() ?? false) {
final v = _formKey.currentState!.value;
final docCubit = BlocProvider.of<DocumentsCubit>(context);
DocumentFilter newFilter = docCubit.state.filter.copyWith(
createdDateBefore: (v[fkCreatedAt] as DateTimeRange?)?.end,
createdDateAfter: (v[fkCreatedAt] as DateTimeRange?)?.start,
correspondent: v[fkCorrespondent] as CorrespondentQuery?,
documentType: v[fkDocumentType] as DocumentTypeQuery?,
storagePath: v[fkStoragePath] as StoragePathQuery?,
tags: v[DocumentModel.tagsKey] as TagsQuery?,
page: 1,
queryText: v[fkQuery] as String?,
addedDateBefore: (v[fkAddedAt] as DateTimeRange?)?.end,
addedDateAfter: (v[fkAddedAt] as DateTimeRange?)?.start,
queryType: v[QueryTypeFormField.fkQueryType] as QueryType,
);
BlocProvider.of<DocumentsCubit>(context).updateFilter(filter: newFilter).then((value) {
BlocProvider.of<SavedViewCubit>(context).resetSelection();
FocusScope.of(context).unfocus();
widget.panelController.close();
setState(() => _isQueryLoading = false);
});
}
}
void _patchFromFilter(DocumentFilter f) {
_formKey.currentState?.patchValue({
fkCorrespondent: f.correspondent,
fkDocumentType: f.documentType,
fkQuery: f.queryText,
fkStoragePath: f.storagePath,
DocumentModel.tagsKey: f.tags,
DocumentModel.titleKey: f.queryText,
QueryTypeFormField.fkQueryType: f.queryType,
fkCreatedAt: _dateTimeRangeOfNullable(
f.createdDateAfter,
f.createdDateBefore,
),
fkAddedAt: _dateTimeRangeOfNullable(
f.addedDateAfter,
f.addedDateBefore,
),
});
}
}

View File

@@ -0,0 +1,51 @@
import 'package:flutter/material.dart';
import 'package:flutter_form_builder/flutter_form_builder.dart';
import 'package:flutter_paperless_mobile/features/documents/model/query_parameters/query_type.dart';
import 'package:flutter_paperless_mobile/generated/l10n.dart';
class QueryTypeFormField extends StatelessWidget {
static const fkQueryType = 'queryType';
final QueryType? initialValue;
final void Function(QueryType)? afterSelected;
const QueryTypeFormField({
super.key,
this.initialValue,
this.afterSelected,
});
@override
Widget build(BuildContext context) {
return FormBuilderField<QueryType>(
builder: (field) => PopupMenuButton<QueryType>(
itemBuilder: (context) => [
PopupMenuItem(
child: ListTile(
title: Text(S.of(context).documentsFilterPageQueryOptionsTitleAndContentLabel),
),
value: QueryType.titleAndContent,
),
PopupMenuItem(
child: ListTile(
title: Text(S.of(context).documentsFilterPageQueryOptionsTitleLabel),
),
value: QueryType.title,
),
PopupMenuItem(
child: ListTile(
title: Text(S.of(context).documentsFilterPageQueryOptionsExtendedLabel),
),
value: QueryType.extended,
),
//TODO: Add support for ASN queries
],
onSelected: (selection) {
field.didChange(selection);
afterSelected?.call(selection);
},
child: const Icon(Icons.more_vert),
),
initialValue: initialValue,
name: QueryTypeFormField.fkQueryType,
);
}
}

View File

@@ -0,0 +1,85 @@
import 'package:flutter/material.dart';
import 'package:flutter_form_builder/flutter_form_builder.dart';
import 'package:flutter_paperless_mobile/features/documents/model/document_filter.dart';
import 'package:flutter_paperless_mobile/features/documents/model/saved_view.model.dart';
import 'package:flutter_paperless_mobile/generated/l10n.dart';
import 'package:form_builder_validators/form_builder_validators.dart';
class AddSavedViewPage extends StatefulWidget {
final DocumentFilter currentFilter;
const AddSavedViewPage({super.key, required this.currentFilter});
@override
State<AddSavedViewPage> createState() => _AddSavedViewPageState();
}
class _AddSavedViewPageState extends State<AddSavedViewPage> {
static const fkName = 'name';
static const fkShowOnDashboard = 'show_on_dashboard';
static const fkShowInSidebar = 'show_in_sidebar';
final GlobalKey<FormBuilderState> _formKey = GlobalKey();
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: Text(S.of(context).savedViewCreateNewLabel),
actions: [
Padding(
padding: const EdgeInsets.all(8.0),
child: Tooltip(
child: const Icon(Icons.info_outline),
message: S.of(context).savedViewCreateTooltipText,
),
),
],
),
floatingActionButton: FloatingActionButton.extended(
icon: const Icon(Icons.add),
onPressed: () => _onCreate(context),
label: Text(S.of(context).genericActionCreateLabel),
),
body: Padding(
padding: const EdgeInsets.all(8.0),
child: FormBuilder(
key: _formKey,
child: ListView(
children: [
FormBuilderTextField(
name: fkName,
validator: FormBuilderValidators.required(), //TODO: INTL
decoration: InputDecoration(
label: Text(S.of(context).savedViewNameLabel),
),
),
FormBuilderCheckbox(
name: fkShowOnDashboard,
initialValue: false,
title: Text(S.of(context).savedViewShowOnDashboardLabel),
),
FormBuilderCheckbox(
name: fkShowInSidebar,
initialValue: false,
title: Text(S.of(context).savedViewShowInSidebarLabel),
),
],
),
),
),
);
}
void _onCreate(BuildContext context) {
if (_formKey.currentState?.saveAndValidate() ?? false) {
Navigator.pop(
context,
SavedView.fromDocumentFilter(
widget.currentFilter,
name: _formKey.currentState?.value[fkName] as String,
showOnDashboard: _formKey.currentState?.value[fkShowOnDashboard] as bool,
showInSidebar: _formKey.currentState?.value[fkShowInSidebar] as bool,
),
);
}
}
}

View File

@@ -0,0 +1,72 @@
import 'package:flutter/material.dart';
import 'package:flutter_paperless_mobile/features/documents/bloc/documents_state.dart';
import 'package:flutter_paperless_mobile/features/documents/model/document.model.dart';
import 'package:flutter_paperless_mobile/generated/l10n.dart';
class BulkDeleteConfirmationDialog extends StatelessWidget {
static const _bulletPoint = "\u2022";
final DocumentsState state;
const BulkDeleteConfirmationDialog({Key? key, required this.state})
: super(key: key);
@override
Widget build(BuildContext context) {
assert(state.selection.isNotEmpty);
return AlertDialog(
title: Text(S.of(context).documentsPageSelectionBulkDeleteDialogTitle),
content: Column(
mainAxisSize: MainAxisSize.min,
children: [
Text(
//TODO: use plurals, didn't use because of crash... investigate later.
state.selection.length == 1
? S
.of(context)
.documentsPageSelectionBulkDeleteDialogWarningTextOne
: S
.of(context)
.documentsPageSelectionBulkDeleteDialogWarningTextMany,
),
const SizedBox(height: 16),
ConstrainedBox(
constraints: const BoxConstraints(maxHeight: 150),
child: ListView(
shrinkWrap: true,
children: state.selection.map(_buildBulletPoint).toList(),
),
),
const SizedBox(height: 16),
Text(
S.of(context).documentsPageSelectionBulkDeleteDialogContinueText),
],
),
actions: [
TextButton(
onPressed: () => Navigator.pop(context, false),
child: Text(S.of(context).genericActionCancelLabel),
),
TextButton(
style: ButtonStyle(
foregroundColor:
MaterialStateProperty.all(Theme.of(context).colorScheme.error),
),
onPressed: () {
Navigator.pop(context, true);
},
child: Text(S.of(context).genericActionDeleteLabel),
),
],
);
}
Widget _buildBulletPoint(DocumentModel doc) {
return Text(
"\t$_bulletPoint ${doc.title}",
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: const TextStyle(
fontWeight: FontWeight.w700,
),
);
}
}

View File

@@ -0,0 +1,40 @@
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:flutter_paperless_mobile/core/model/error_message.dart';
import 'package:flutter_paperless_mobile/features/documents/bloc/saved_view_cubit.dart';
import 'package:flutter_paperless_mobile/features/documents/model/saved_view.model.dart';
import 'package:flutter_paperless_mobile/generated/l10n.dart';
import 'package:flutter_paperless_mobile/util.dart';
class ConfirmDeleteSavedViewDialog extends StatelessWidget {
const ConfirmDeleteSavedViewDialog({
Key? key,
required this.view,
}) : super(key: key);
final SavedView view;
@override
Widget build(BuildContext context) {
return AlertDialog(
title: Text(
"Delete view " + view.name + "?",
softWrap: true,
),
content: Text("Do you really want to delete this view?"),
actions: [
TextButton(
child: Text(S.of(context).genericActionCancelLabel),
onPressed: () => Navigator.pop(context, false),
),
TextButton(
child: Text(
S.of(context).genericActionDeleteLabel,
style: TextStyle(color: Theme.of(context).colorScheme.error),
),
onPressed: () => Navigator.pop(context, true),
),
],
);
}
}

View File

@@ -0,0 +1,91 @@
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:flutter_paperless_mobile/core/logic/error_code_localization_mapper.dart';
import 'package:flutter_paperless_mobile/core/model/error_message.dart';
import 'package:flutter_paperless_mobile/features/documents/bloc/documents_cubit.dart';
import 'package:flutter_paperless_mobile/features/documents/bloc/documents_state.dart';
import 'package:flutter_paperless_mobile/features/documents/view/widgets/selection/bulk_delete_confirmation_dialog.dart';
import 'package:flutter_paperless_mobile/features/documents/view/widgets/selection/saved_view_selection_widget.dart';
import 'package:flutter_paperless_mobile/generated/l10n.dart';
import 'package:flutter_paperless_mobile/util.dart';
class DocumentsPageAppBar extends StatefulWidget with PreferredSizeWidget {
final List<Widget> actions;
const DocumentsPageAppBar({
super.key,
this.actions = const [],
});
@override
Size get preferredSize => const Size.fromHeight(kToolbarHeight);
@override
State<DocumentsPageAppBar> createState() => _DocumentsPageAppBarState();
}
class _DocumentsPageAppBarState extends State<DocumentsPageAppBar> {
static const _flexibleAreaHeight = kToolbarHeight + 48.0;
@override
Widget build(BuildContext context) {
return BlocBuilder<DocumentsCubit, DocumentsState>(
builder: (context, documentsState) {
if (documentsState.selection.isNotEmpty) {
return SliverAppBar(
snap: true,
floating: true,
pinned: true,
expandedHeight: kToolbarHeight,
leading: IconButton(
icon: const Icon(Icons.close),
onPressed: () => BlocProvider.of<DocumentsCubit>(context).resetSelection(),
),
title:
Text('${documentsState.selection.length} ${S.of(context).documentsSelectedText}'),
actions: [
IconButton(
icon: const Icon(Icons.delete),
onPressed: () => _onDelete(context, documentsState),
),
],
);
} else {
return SliverAppBar(
expandedHeight: kToolbarHeight + _flexibleAreaHeight,
pinned: true,
flexibleSpace: const FlexibleSpaceBar(
background: Padding(
padding: EdgeInsets.all(8.0),
child: SavedViewSelectionWidget(height: _flexibleAreaHeight),
),
),
title: BlocBuilder<DocumentsCubit, DocumentsState>(
builder: (context, state) {
return Text(
'${S.of(context).documentsPageTitle} (${_formatDocumentCount(state.count)})',
);
},
),
actions: widget.actions,
);
}
},
);
}
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))
.onError<ErrorMessage>(
(error, _) => showSnackBar(context, translateError(context, error.code)));
}
}
String _formatDocumentCount(int count) {
return count > 99 ? "99+" : count.toString();
}
}

View File

@@ -0,0 +1,117 @@
import 'dart:developer';
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:flutter_paperless_mobile/core/model/error_message.dart';
import 'package:flutter_paperless_mobile/di_initializer.dart';
import 'package:flutter_paperless_mobile/features/documents/bloc/documents_cubit.dart';
import 'package:flutter_paperless_mobile/features/documents/bloc/saved_view_cubit.dart';
import 'package:flutter_paperless_mobile/features/documents/bloc/saved_view_state.dart';
import 'package:flutter_paperless_mobile/features/documents/model/saved_view.model.dart';
import 'package:flutter_paperless_mobile/features/documents/view/widgets/selection/add_saved_view_page.dart';
import 'package:flutter_paperless_mobile/features/documents/view/widgets/selection/confirm_delete_saved_view_dialog.dart';
import 'package:flutter_paperless_mobile/generated/l10n.dart';
import 'package:flutter_paperless_mobile/util.dart';
class SavedViewSelectionWidget extends StatelessWidget {
const SavedViewSelectionWidget({
Key? key,
required this.height,
}) : super(key: key);
final double height;
@override
Widget build(BuildContext context) {
return Column(
mainAxisAlignment: MainAxisAlignment.end,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
BlocBuilder<SavedViewCubit, SavedViewState>(
builder: (context, state) {
if (state.value.isEmpty) {
return Text(S.of(context).savedViewsEmptyStateText);
}
return SizedBox(
height: 48.0,
child: ListView.separated(
itemCount: state.value.length,
scrollDirection: Axis.horizontal,
itemBuilder: (context, index) {
final view = state.value.values.elementAt(index);
return GestureDetector(
onLongPress: () => _onDelete(context, view),
child: FilterChip(
label: Text(state.value.values.toList()[index].name),
selected: view.id == state.selectedSavedViewId,
onSelected: (isSelected) => _onSelected(isSelected, context, view),
),
);
},
separatorBuilder: (context, index) => const SizedBox(
width: 8.0,
),
),
);
},
),
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text(
S.of(context).savedViewsLabel,
style: Theme.of(context).textTheme.titleSmall,
),
TextButton.icon(
icon: const Icon(Icons.add),
onPressed: () => _onCreatePressed(context),
label: Text(S.of(context).savedViewCreateNewLabel),
),
],
),
],
);
}
void _onCreatePressed(BuildContext context) async {
final newView = await Navigator.of(context).push<SavedView?>(
MaterialPageRoute(
builder: (context) => AddSavedViewPage(currentFilter: getIt<DocumentsCubit>().state.filter),
),
);
if (newView != null) {
try {
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 _onDelete(BuildContext context, SavedView view) async {
{
final delete = await showDialog<bool>(
context: context,
builder: (context) => ConfirmDeleteSavedViewDialog(view: view),
) ??
false;
if (delete) {
try {
BlocProvider.of<SavedViewCubit>(context).remove(view);
} on ErrorMessage catch (error) {
showError(context, error);
}
}
}
}
}

View File

@@ -0,0 +1,55 @@
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:flutter_paperless_mobile/features/documents/bloc/documents_cubit.dart';
import 'package:flutter_paperless_mobile/features/documents/bloc/documents_state.dart';
import 'package:flutter_paperless_mobile/features/documents/model/query_parameters/sort_order.dart';
import 'package:font_awesome_flutter/font_awesome_flutter.dart';
class SortDocumentsButton extends StatefulWidget {
const SortDocumentsButton({
Key? key,
}) : super(key: key);
@override
State<SortDocumentsButton> createState() => _SortDocumentsButtonState();
}
class _SortDocumentsButtonState extends State<SortDocumentsButton> {
bool _isLoading = false;
@override
Widget build(BuildContext context) {
return BlocBuilder<DocumentsCubit, DocumentsState>(
builder: (context, state) {
Widget child;
if (_isLoading) {
child = const FittedBox(
fit: BoxFit.scaleDown,
child: RefreshProgressIndicator(
strokeWidth: 4.0,
backgroundColor: Colors.transparent,
),
);
} else {
final bool isAscending = state.filter.sortOrder == SortOrder.ascending;
child = IconButton(
icon: FaIcon(
isAscending ? FontAwesomeIcons.arrowDownAZ : FontAwesomeIcons.arrowUpZA,
),
onPressed: () async {
setState(() => _isLoading = true);
BlocProvider.of<DocumentsCubit>(context)
.updateFilter(
filter: state.filter.copyWith(sortOrder: state.filter.sortOrder.toggle()))
.whenComplete(() => setState(() => _isLoading = false));
},
);
}
return SizedBox(
height: Theme.of(context).iconTheme.size,
width: Theme.of(context).iconTheme.size,
child: child,
);
},
);
}
}

View File

@@ -0,0 +1,76 @@
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:flutter_paperless_mobile/core/bloc/connectivity_cubit.dart';
import 'package:flutter_paperless_mobile/di_initializer.dart';
import 'package:flutter_paperless_mobile/features/documents/bloc/documents_cubit.dart';
import 'package:flutter_paperless_mobile/features/documents/bloc/saved_view_cubit.dart';
import 'package:flutter_paperless_mobile/features/documents/model/saved_view.model.dart';
import 'package:flutter_paperless_mobile/features/documents/view/pages/documents_page.dart';
import 'package:flutter_paperless_mobile/features/home/view/widget/bottom_navigation_bar.dart';
import 'package:flutter_paperless_mobile/features/home/view/widget/info_drawer.dart';
import 'package:flutter_paperless_mobile/features/labels/correspondent/bloc/correspondents_cubit.dart';
import 'package:flutter_paperless_mobile/features/labels/document_type/bloc/document_type_cubit.dart';
import 'package:flutter_paperless_mobile/features/labels/storage_path/bloc/storage_path_cubit.dart';
import 'package:flutter_paperless_mobile/features/labels/tags/bloc/tags_cubit.dart';
import 'package:flutter_paperless_mobile/features/labels/view/pages/labels_page.dart';
import 'package:flutter_paperless_mobile/features/scan/bloc/document_scanner_cubit.dart';
import 'package:flutter_paperless_mobile/features/scan/view/scanner_page.dart';
import 'package:flutter_paperless_mobile/util.dart';
class HomePage extends StatefulWidget {
const HomePage({Key? key}) : super(key: key);
@override
_HomePageState createState() => _HomePageState();
}
class _HomePageState extends State<HomePage> {
int _currentIndex = 0;
@override
void initState() {
super.initState();
initializeLabelData(context);
}
@override
Widget build(BuildContext context) {
return BlocListener<ConnectivityCubit, ConnectivityState>(
//Only re-initialize data if the connectivity changed from not connected to connected
listenWhen: (previous, current) =>
previous != ConnectivityState.connected && current == ConnectivityState.connected,
listener: (context, state) {
initializeLabelData(context);
},
child: Scaffold(
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(),
),
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();
}
}

View File

@@ -0,0 +1,36 @@
import 'package:flutter/material.dart';
import 'package:flutter_paperless_mobile/generated/l10n.dart';
class BottomNavBar extends StatelessWidget {
final int selectedIndex;
final void Function(int) onNavigationChanged;
const BottomNavBar(
{Key? key,
required this.selectedIndex,
required this.onNavigationChanged})
: super(key: key);
@override
Widget build(BuildContext context) {
return NavigationBar(
elevation: 4.0,
onDestinationSelected: onNavigationChanged,
selectedIndex: selectedIndex,
destinations: [
NavigationDestination(
icon: const Icon(Icons.description),
label: S.of(context).bottomNavDocumentsPageLabel,
),
NavigationDestination(
icon: const Icon(Icons.document_scanner),
label: S.of(context).bottomNavScannerPageLabel,
),
NavigationDestination(
icon: const Icon(Icons.sell),
label: S.of(context).bottomNavLabelsPageLabel,
),
],
);
}
}

View File

@@ -0,0 +1,117 @@
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:flutter_paperless_mobile/features/settings/bloc/application_settings_cubit.dart';
import 'package:flutter_paperless_mobile/di_initializer.dart';
import 'package:flutter_paperless_mobile/features/labels/correspondent/bloc/correspondents_cubit.dart';
import 'package:flutter_paperless_mobile/features/labels/document_type/bloc/document_type_cubit.dart';
import 'package:flutter_paperless_mobile/features/documents/bloc/documents_cubit.dart';
import 'package:flutter_paperless_mobile/features/settings/view/settings_page.dart';
import 'package:flutter_paperless_mobile/features/login/bloc/authentication_cubit.dart';
import 'package:flutter_paperless_mobile/features/scan/bloc/document_scanner_cubit.dart';
import 'package:flutter_paperless_mobile/features/labels/tags/bloc/tags_cubit.dart';
import 'package:flutter_paperless_mobile/generated/l10n.dart';
import 'package:flutter_paperless_mobile/util.dart';
import 'package:url_launcher/url_launcher_string.dart';
import 'package:flutter_paperless_mobile/extensions/flutter_extensions.dart';
class InfoDrawer extends StatelessWidget {
const InfoDrawer({Key? key}) : super(key: key);
@override
Widget build(BuildContext context) {
return Drawer(
child: ListView(
children: [
DrawerHeader(
child: Column(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Image.asset(
"assets/logos/paperless_logo_white.png",
height: 32,
width: 32,
color: Theme.of(context).colorScheme.onPrimaryContainer,
).padded(const EdgeInsets.only(right: 8.0)),
Text(
S.of(context).appTitleText,
style: Theme.of(context)
.textTheme
.headline5!
.copyWith(color: Theme.of(context).colorScheme.onPrimaryContainer),
),
],
),
Align(
alignment: Alignment.bottomRight,
child: BlocBuilder<AuthenticationCubit, AuthenticationState>(
builder: (context, state) {
return Text(
state.authentication?.serverUrl.replaceAll(RegExp(r'https?://'), "") ?? "",
textAlign: TextAlign.end,
style: TextStyle(color: Theme.of(context).colorScheme.onPrimaryContainer),
);
},
),
),
],
),
decoration: BoxDecoration(
color: Theme.of(context).colorScheme.primaryContainer,
),
),
ListTile(
leading: const Icon(Icons.settings),
title: Text(
S.of(context).appDrawerSettingsLabel,
),
onTap: () => Navigator.of(context).push(
MaterialPageRoute(
builder: (context) => BlocProvider.value(
value: getIt<ApplicationSettingsCubit>(),
child: const SettingsPage(),
),
),
),
),
const Divider(),
ListTile(
leading: const Icon(Icons.bug_report),
title: Text(S.of(context).appDrawerReportBugLabel),
onTap: () {
launchUrlString("https://github.com/astubenbord/paperless-mobile/issues/new");
},
),
const Divider(),
AboutListTile(
icon: const Icon(Icons.info),
applicationIcon: const ImageIcon(AssetImage("assets/logos/paperless_logo_green.png")),
applicationName: "Paperless Mobile",
applicationVersion: kPackageInfo.version + "+" + kPackageInfo.buildNumber,
aboutBoxChildren: [
Text('${S.of(context).aboutDialogDevelopedByText} Anton Stubenbord'),
],
child: Text(S.of(context).appDrawerAboutLabel),
),
const Divider(),
ListTile(
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();
},
),
const Divider(),
],
),
);
}
}

View File

@@ -0,0 +1,22 @@
import 'package:flutter_paperless_mobile/core/bloc/label_cubit.dart';
import 'package:flutter_paperless_mobile/features/labels/correspondent/model/correspondent.model.dart';
import 'package:injectable/injectable.dart';
@singleton
class CorrespondentCubit extends LabelCubit<Correspondent> {
CorrespondentCubit(super.metaDataService);
@override
Future<void> initialize() async {
return labelRepository.getCorrespondents().then(loadFrom);
}
@override
Future<Correspondent> save(Correspondent item) => labelRepository.saveCorrespondent(item);
@override
Future<Correspondent> update(Correspondent item) => labelRepository.updateCorrespondent(item);
@override
Future<int> delete(Correspondent item) => labelRepository.deleteCorrespondent(item);
}

View File

@@ -0,0 +1,65 @@
import 'package:flutter_paperless_mobile/core/type/json.dart';
import 'package:flutter_paperless_mobile/extensions/dart_extensions.dart';
import 'package:flutter_paperless_mobile/features/labels/document_type/model/matching_algorithm.dart';
import 'package:flutter_paperless_mobile/features/labels/model/label.model.dart';
class Correspondent extends Label {
static const lastCorrespondenceKey = 'last_correspondence';
late DateTime? lastCorrespondence;
Correspondent({
required super.id,
required super.name,
super.slug,
super.match,
super.matchingAlgorithm,
super.isInsensitive,
super.documentCount,
this.lastCorrespondence,
});
Correspondent.fromJson(JSON json)
: lastCorrespondence =
DateTime.tryParse(json[lastCorrespondenceKey] ?? ''),
super.fromJson(json);
@override
String toString() {
return name;
}
@override
void addSpecificFieldsToJson(JSON json) {
json.tryPutIfAbsent(
lastCorrespondenceKey,
() => lastCorrespondence?.toIso8601String(),
);
}
@override
Correspondent copyWith({
int? id,
String? name,
String? slug,
String? match,
MatchingAlgorithm? matchingAlgorithm,
bool? isInsensitive,
int? documentCount,
DateTime? lastCorrespondence,
}) {
return Correspondent(
id: id ?? this.id,
name: name ?? this.name,
documentCount: documentCount ?? documentCount,
isInsensitive: isInsensitive ?? isInsensitive,
lastCorrespondence: lastCorrespondence ?? this.lastCorrespondence,
match: match ?? this.match,
matchingAlgorithm: matchingAlgorithm ?? this.matchingAlgorithm,
slug: slug ?? this.slug,
);
}
@override
String get queryEndpoint => 'correspondents';
}

View File

@@ -0,0 +1,21 @@
import 'package:flutter/widgets.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:flutter_paperless_mobile/features/labels/correspondent/bloc/correspondents_cubit.dart';
import 'package:flutter_paperless_mobile/features/labels/correspondent/model/correspondent.model.dart';
import 'package:flutter_paperless_mobile/features/labels/view/pages/add_label_page.dart';
import 'package:flutter_paperless_mobile/generated/l10n.dart';
class AddCorrespondentPage extends StatelessWidget {
final String? initalValue;
const AddCorrespondentPage({Key? key, this.initalValue}) : super(key: key);
@override
Widget build(BuildContext context) {
return AddLabelPage<Correspondent>(
addLabelStr: S.of(context).addCorrespondentPageTitle,
fromJson: Correspondent.fromJson,
cubit: BlocProvider.of<CorrespondentCubit>(context),
initialName: initalValue,
);
}
}

View File

@@ -0,0 +1,41 @@
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:flutter_paperless_mobile/core/logic/error_code_localization_mapper.dart';
import 'package:flutter_paperless_mobile/core/model/error_message.dart';
import 'package:flutter_paperless_mobile/features/documents/bloc/documents_cubit.dart';
import 'package:flutter_paperless_mobile/features/documents/model/query_parameters/correspondent_query.dart';
import 'package:flutter_paperless_mobile/features/labels/correspondent/bloc/correspondents_cubit.dart';
import 'package:flutter_paperless_mobile/features/labels/correspondent/model/correspondent.model.dart';
import 'package:flutter_paperless_mobile/features/labels/view/pages/edit_label_page.dart';
import 'package:flutter_paperless_mobile/util.dart';
class EditCorrespondentPage extends StatelessWidget {
final Correspondent correspondent;
const EditCorrespondentPage({super.key, required this.correspondent});
@override
Widget build(BuildContext context) {
return EditLabelPage<Correspondent>(
label: correspondent,
onSubmit: BlocProvider.of<CorrespondentCubit>(context).replace,
onDelete: (correspondent) => _onDelete(correspondent, context),
fromJson: Correspondent.fromJson,
);
}
Future<void> _onDelete(Correspondent correspondent, BuildContext context) async {
try {
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()),
);
}
} on ErrorMessage catch (e) {
showSnackBar(context, translateError(context, e.code));
} finally {
Navigator.pop(context);
}
}
}

View File

@@ -0,0 +1,59 @@
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:flutter_paperless_mobile/di_initializer.dart';
import 'package:flutter_paperless_mobile/features/documents/bloc/documents_cubit.dart';
import 'package:flutter_paperless_mobile/features/documents/model/query_parameters/correspondent_query.dart';
import 'package:flutter_paperless_mobile/features/labels/correspondent/bloc/correspondents_cubit.dart';
import 'package:flutter_paperless_mobile/features/labels/correspondent/model/correspondent.model.dart';
class CorrespondentWidget extends StatelessWidget {
final int? correspondentId;
final void Function()? afterSelected;
final Color? textColor;
final bool isClickable;
const CorrespondentWidget({
Key? key,
required this.correspondentId,
this.afterSelected,
this.textColor,
this.isClickable = true,
}) : super(key: key);
@override
Widget build(BuildContext context) {
return AbsorbPointer(
absorbing: !isClickable,
child: BlocBuilder<CorrespondentCubit, Map<int, Correspondent>>(
builder: (context, state) {
return GestureDetector(
onTap: () => _addCorrespondentToFilter(context),
child: Text(
(state[correspondentId]?.name) ?? "-",
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: Theme.of(context).textTheme.bodyText2?.copyWith(
color: textColor ?? Theme.of(context).colorScheme.primary,
),
),
);
},
),
);
}
void _addCorrespondentToFilter(BuildContext context) {
final cubit = getIt<DocumentsCubit>();
if (cubit.state.filter.correspondent.id == correspondentId) {
cubit.updateFilter(
filter: cubit.state.filter.copyWith(correspondent: const CorrespondentQuery.unset()));
} else {
cubit.updateFilter(
filter: cubit.state.filter.copyWith(
correspondent: CorrespondentQuery.fromId(correspondentId),
),
);
}
afterSelected?.call();
}
}

View File

@@ -0,0 +1,22 @@
import 'package:flutter_paperless_mobile/core/bloc/label_cubit.dart';
import 'package:flutter_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);
@override
Future<void> initialize() async {
return labelRepository.getDocumentTypes().then(loadFrom);
}
@override
Future<DocumentType> save(DocumentType item) => labelRepository.saveDocumentType(item);
@override
Future<DocumentType> update(DocumentType item) => labelRepository.updateDocumentType(item);
@override
Future<int> delete(DocumentType item) => labelRepository.deleteDocumentType(item);
}

View File

@@ -0,0 +1,44 @@
import 'package:flutter_paperless_mobile/core/type/json.dart';
import 'package:flutter_paperless_mobile/features/labels/document_type/model/matching_algorithm.dart';
import 'package:flutter_paperless_mobile/features/labels/model/label.model.dart';
class DocumentType extends Label {
DocumentType({
required super.id,
required super.name,
super.slug,
super.match,
super.matchingAlgorithm,
super.isInsensitive,
super.documentCount,
});
DocumentType.fromJson(JSON json) : super.fromJson(json);
@override
void addSpecificFieldsToJson(JSON json) {}
@override
String get queryEndpoint => 'document_types';
@override
DocumentType copyWith({
int? id,
String? name,
String? match,
MatchingAlgorithm? matchingAlgorithm,
bool? isInsensitive,
int? documentCount,
String? slug,
}) {
return DocumentType(
id: id ?? this.id,
name: name ?? this.name,
match: match ?? this.match,
matchingAlgorithm: matchingAlgorithm ?? this.matchingAlgorithm,
isInsensitive: isInsensitive ?? this.isInsensitive,
documentCount: documentCount ?? this.documentCount,
slug: slug ?? this.slug,
);
}
}

View File

@@ -0,0 +1,22 @@
enum MatchingAlgorithm {
anyWord(1, "Any: Match one of the following words"),
allWords(2, "All: Match all of the following words"),
exactMatch(3, "Exact: Match the following string"),
regex(4, "Regex: Match the regular expression"),
similarWord(5, "Similar: Match a similar word"),
auto(6, "Auto: Learn automatic assignment");
final int value;
final String name;
const MatchingAlgorithm(this.value, this.name);
static MatchingAlgorithm fromInt(int? value) {
return MatchingAlgorithm.values
.where((element) => element.value == value)
.firstWhere(
(element) => true,
orElse: () => MatchingAlgorithm.anyWord,
);
}
}

View File

@@ -0,0 +1,21 @@
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:flutter_paperless_mobile/features/labels/document_type/bloc/document_type_cubit.dart';
import 'package:flutter_paperless_mobile/features/labels/document_type/model/document_type.model.dart';
import 'package:flutter_paperless_mobile/features/labels/view/pages/add_label_page.dart';
import 'package:flutter_paperless_mobile/generated/l10n.dart';
class AddDocumentTypePage extends StatelessWidget {
final String? initialName;
const AddDocumentTypePage({Key? key, this.initialName}) : super(key: key);
@override
Widget build(BuildContext context) {
return AddLabelPage<DocumentType>(
addLabelStr: S.of(context).addDocumentTypePageTitle,
fromJson: DocumentType.fromJson,
cubit: BlocProvider.of<DocumentTypeCubit>(context),
initialName: initialName,
);
}
}

View File

@@ -0,0 +1,41 @@
import 'package:flutter/widgets.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:flutter_paperless_mobile/core/logic/error_code_localization_mapper.dart';
import 'package:flutter_paperless_mobile/core/model/error_message.dart';
import 'package:flutter_paperless_mobile/features/documents/bloc/documents_cubit.dart';
import 'package:flutter_paperless_mobile/features/documents/model/query_parameters/document_type_query.dart';
import 'package:flutter_paperless_mobile/features/labels/document_type/bloc/document_type_cubit.dart';
import 'package:flutter_paperless_mobile/features/labels/document_type/model/document_type.model.dart';
import 'package:flutter_paperless_mobile/features/labels/view/pages/edit_label_page.dart';
import 'package:flutter_paperless_mobile/util.dart';
class EditDocumentTypePage extends StatelessWidget {
final DocumentType documentType;
const EditDocumentTypePage({super.key, required this.documentType});
@override
Widget build(BuildContext context) {
return EditLabelPage<DocumentType>(
label: documentType,
onSubmit: BlocProvider.of<DocumentTypeCubit>(context).replace,
onDelete: (docType) => _onDelete(docType, context),
fromJson: DocumentType.fromJson,
);
}
Future<void> _onDelete(DocumentType docType, BuildContext context) async {
try {
await BlocProvider.of<DocumentTypeCubit>(context).remove(docType);
final cubit = BlocProvider.of<DocumentsCubit>(context);
if (cubit.state.filter.documentType.id == docType.id) {
cubit.updateFilter(
filter: cubit.state.filter.copyWith(documentType: const DocumentTypeQuery.unset()),
);
}
} on ErrorMessage catch (e) {
showSnackBar(context, translateError(context, e.code));
} finally {
Navigator.pop(context);
}
}
}

View File

@@ -0,0 +1,52 @@
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:flutter_paperless_mobile/di_initializer.dart';
import 'package:flutter_paperless_mobile/features/documents/model/query_parameters/document_type_query.dart';
import 'package:flutter_paperless_mobile/features/labels/document_type/bloc/document_type_cubit.dart';
import 'package:flutter_paperless_mobile/features/documents/bloc/documents_cubit.dart';
import 'package:flutter_paperless_mobile/features/labels/document_type/model/document_type.model.dart';
class DocumentTypeWidget extends StatelessWidget {
final int? documentTypeId;
final void Function()? afterSelected;
const DocumentTypeWidget({
Key? key,
required this.documentTypeId,
this.afterSelected,
}) : super(key: key);
@override
Widget build(BuildContext context) {
return GestureDetector(
onTap: _addDocumentTypeToFilter,
child: BlocBuilder<DocumentTypeCubit, Map<int, DocumentType>>(
builder: (context, state) {
return Text(
state[documentTypeId]?.toString() ?? "-",
style: Theme.of(context)
.textTheme
.bodyText2!
.copyWith(color: Theme.of(context).colorScheme.primary),
);
},
),
);
}
void _addDocumentTypeToFilter() {
final cubit = getIt<DocumentsCubit>();
if (cubit.state.filter.documentType.id == documentTypeId) {
cubit.updateFilter(
filter: cubit.state.filter.copyWith(documentType: const DocumentTypeQuery.unset()));
} else {
cubit.updateFilter(
filter: cubit.state.filter.copyWith(
documentType: DocumentTypeQuery.fromId(documentTypeId),
),
);
}
if (afterSelected != null) {
afterSelected?.call();
}
}
}

View File

@@ -0,0 +1,82 @@
import 'package:equatable/equatable.dart';
import 'package:flutter_paperless_mobile/core/type/json.dart';
import 'package:flutter_paperless_mobile/extensions/dart_extensions.dart';
import 'package:flutter_paperless_mobile/features/labels/document_type/model/matching_algorithm.dart';
abstract class Label with EquatableMixin implements Comparable {
static const idKey = "id";
static const nameKey = "name";
static const slugKey = "slug";
static const matchKey = "match";
static const matchingAlgorithmKey = "matching_algorithm";
static const isInsensitiveKey = "is_insensitive";
static const documentCountKey = "document_count";
String get queryEndpoint;
final int? id;
final String name;
final String? slug;
final String? match;
final MatchingAlgorithm? matchingAlgorithm;
final bool? isInsensitive;
final int? documentCount;
const Label({
required this.id,
required this.name,
this.match,
this.matchingAlgorithm,
this.isInsensitive,
this.documentCount,
this.slug,
});
Label.fromJson(JSON json)
: id = json[idKey],
name = json[nameKey],
slug = json[slugKey],
match = json[matchKey],
matchingAlgorithm =
MatchingAlgorithm.fromInt(json[matchingAlgorithmKey]),
isInsensitive = json[isInsensitiveKey],
documentCount = json[documentCountKey];
JSON toJson() {
JSON json = {};
json.tryPutIfAbsent(idKey, () => id);
json.tryPutIfAbsent(nameKey, () => name);
json.tryPutIfAbsent(slugKey, () => slug);
json.tryPutIfAbsent(matchKey, () => match);
json.tryPutIfAbsent(matchingAlgorithmKey, () => matchingAlgorithm?.value);
json.tryPutIfAbsent(isInsensitiveKey, () => isInsensitive);
json.tryPutIfAbsent(documentCountKey, () => documentCount);
addSpecificFieldsToJson(json);
return json;
}
void addSpecificFieldsToJson(JSON json);
Label copyWith({
int? id,
String? name,
String? match,
MatchingAlgorithm? matchingAlgorithm,
bool? isInsensitive,
int? documentCount,
String? slug,
});
@override
String toString() {
return name;
}
@override
int compareTo(dynamic other) {
return toString().toLowerCase().compareTo(other.toString().toLowerCase());
}
@override
List<Object?> get props => [id];
}

View File

@@ -0,0 +1,246 @@
import 'dart:convert';
import 'package:flutter_paperless_mobile/core/model/error_message.dart';
import 'package:flutter_paperless_mobile/core/util.dart';
import 'package:flutter_paperless_mobile/di_initializer.dart';
import 'package:flutter_paperless_mobile/features/labels/correspondent/model/correspondent.model.dart';
import 'package:flutter_paperless_mobile/features/labels/document_type/model/document_type.model.dart';
import 'package:flutter_paperless_mobile/features/labels/repository/label_repository.dart';
import 'package:flutter_paperless_mobile/features/labels/storage_path/model/storage_path.model.dart';
import 'package:flutter_paperless_mobile/features/labels/tags/model/tag.model.dart';
import 'package:http/http.dart';
import 'package:injectable/injectable.dart';
@Singleton(as: LabelRepository)
class LabelRepositoryImpl implements LabelRepository {
final BaseClient httpClient;
LabelRepositoryImpl(@Named("timeoutClient") this.httpClient);
@override
Future<Correspondent?> getCorrespondent(int id) async {
return getSingleResult(
"/api/correspondents/$id/",
Correspondent.fromJson,
ErrorCode.correspondentLoadFailed,
);
}
@override
Future<Tag?> getTag(int id) async {
return getSingleResult("/api/tags/$id/", Tag.fromJson, ErrorCode.tagLoadFailed);
}
@override
Future<List<Tag>> getTags({List<int>? ids}) async {
final results = await getCollection(
"/api/tags/?page=1&page_size=100000",
Tag.fromJson,
ErrorCode.tagLoadFailed,
minRequiredApiVersion: 2,
);
return results.where((element) => ids?.contains(element.id) ?? true).toList();
}
@override
Future<DocumentType?> getDocumentType(int id) async {
return getSingleResult(
"/api/document_types/$id/",
DocumentType.fromJson,
ErrorCode.documentTypeLoadFailed,
);
}
@override
Future<List<Correspondent>> getCorrespondents() {
return getCollection(
"/api/correspondents/?page=1&page_size=100000",
Correspondent.fromJson,
ErrorCode.correspondentLoadFailed,
);
}
@override
Future<List<DocumentType>> getDocumentTypes() {
return getCollection(
"/api/document_types/?page=1&page_size=100000",
DocumentType.fromJson,
ErrorCode.documentTypeLoadFailed,
);
}
@override
Future<Correspondent> saveCorrespondent(Correspondent correspondent) async {
final response = await httpClient.post(
Uri.parse('/api/correspondents/'),
body: json.encode(correspondent.toJson()),
headers: {"Content-Type": "application/json"},
);
if (response.statusCode == 201) {
return Correspondent.fromJson(json.decode(response.body));
}
throw ErrorMessage(ErrorCode.correspondentCreateFailed, httpStatusCode: response.statusCode);
}
@override
Future<DocumentType> saveDocumentType(DocumentType type) async {
final response = await httpClient.post(
Uri.parse('/api/document_types/'),
body: json.encode(type.toJson()),
headers: {"Content-Type": "application/json"},
);
if (response.statusCode == 201) {
return DocumentType.fromJson(json.decode(response.body));
}
throw const ErrorMessage(ErrorCode.documentTypeCreateFailed);
}
@override
Future<Tag> saveTag(Tag tag) async {
final body = json.encode(tag.toJson());
final response = await httpClient.post(Uri.parse('/api/tags/'), body: body, headers: {
"Content-Type": "application/json",
"Accept": "application/json; version=2",
});
if (response.statusCode == 201) {
return Tag.fromJson(json.decode(response.body));
}
throw const ErrorMessage(ErrorCode.tagCreateFailed);
}
@override
Future<int> getStatistics() async {
final response = await httpClient.get(Uri.parse('/api/statistics/'));
if (response.statusCode == 200) {
return json.decode(response.body)['documents_total'];
}
throw const ErrorMessage(ErrorCode.unknown);
}
@override
Future<int> deleteCorrespondent(Correspondent correspondent) async {
assert(correspondent.id != null);
final response = await httpClient.delete(Uri.parse('/api/correspondents/${correspondent.id}/'));
if (response.statusCode == 204) {
return correspondent.id!;
}
throw const ErrorMessage(ErrorCode.unknown);
}
@override
Future<int> deleteDocumentType(DocumentType documentType) async {
assert(documentType.id != null);
final response = await httpClient.delete(Uri.parse('/api/document_types/${documentType.id}/'));
if (response.statusCode == 204) {
return documentType.id!;
}
throw const ErrorMessage(ErrorCode.unknown);
}
@override
Future<int> deleteTag(Tag tag) async {
assert(tag.id != null);
final response = await httpClient.delete(Uri.parse('/api/tags/${tag.id}/'));
if (response.statusCode == 204) {
return tag.id!;
}
throw const ErrorMessage(ErrorCode.unknown);
}
@override
Future<Correspondent> updateCorrespondent(Correspondent correspondent) async {
assert(correspondent.id != null);
final response = await httpClient.put(
Uri.parse('/api/correspondents/${correspondent.id}/'),
headers: {"Content-Type": "application/json"},
body: json.encode(correspondent.toJson()),
);
if (response.statusCode == 200) {
return Correspondent.fromJson(json.decode(response.body));
}
throw const ErrorMessage(ErrorCode.unknown);
}
@override
Future<DocumentType> updateDocumentType(DocumentType documentType) async {
assert(documentType.id != null);
final response = await httpClient.put(
Uri.parse('/api/document_types/${documentType.id}/'),
headers: {"Content-Type": "application/json"},
body: json.encode(documentType.toJson()),
);
if (response.statusCode == 200) {
return DocumentType.fromJson(json.decode(response.body));
}
throw const ErrorMessage(ErrorCode.unknown);
}
@override
Future<Tag> updateTag(Tag tag) async {
assert(tag.id != null);
final response = await httpClient.put(
Uri.parse('/api/tags/${tag.id}/'),
headers: {
"Accept": "application/json; version=2",
"Content-Type": "application/json",
},
body: json.encode(tag.toJson()),
);
if (response.statusCode == 200) {
return Tag.fromJson(json.decode(response.body));
}
throw const ErrorMessage(ErrorCode.unknown);
}
@override
Future<int> deleteStoragePath(StoragePath path) async {
assert(path.id != null);
final response = await httpClient.delete(Uri.parse('/api/storage_paths/${path.id}/'));
if (response.statusCode == 204) {
return path.id!;
}
throw const ErrorMessage(ErrorCode.unknown);
}
@override
Future<StoragePath?> getStoragePath(int id) {
return getSingleResult("/api/storage_paths/?page=1&page_size=100000", StoragePath.fromJson,
ErrorCode.storagePathLoadFailed);
}
@override
Future<List<StoragePath>> getStoragePaths() {
return getCollection(
"/api/storage_paths/?page=1&page_size=100000",
StoragePath.fromJson,
ErrorCode.storagePathLoadFailed,
);
}
@override
Future<StoragePath> saveStoragePath(StoragePath path) async {
final response = await httpClient.post(
Uri.parse('/api/storage_paths/'),
body: json.encode(path.toJson()),
headers: {"Content-Type": "application/json"},
);
if (response.statusCode == 201) {
return StoragePath.fromJson(json.decode(response.body));
}
throw ErrorMessage(ErrorCode.storagePathCreateFailed, httpStatusCode: response.statusCode);
}
@override
Future<StoragePath> updateStoragePath(StoragePath path) async {
assert(path.id != null);
final response = await httpClient.put(
Uri.parse('/api/storage_paths/${path.id}/'),
headers: {"Content-Type": "application/json"},
body: json.encode(path.toJson()),
);
if (response.statusCode == 200) {
return StoragePath.fromJson(json.decode(response.body));
}
throw const ErrorMessage(ErrorCode.unknown);
}
}

View File

@@ -0,0 +1,32 @@
import 'package:flutter_paperless_mobile/features/labels/correspondent/model/correspondent.model.dart';
import 'package:flutter_paperless_mobile/features/labels/document_type/model/document_type.model.dart';
import 'package:flutter_paperless_mobile/features/labels/storage_path/model/storage_path.model.dart';
import 'package:flutter_paperless_mobile/features/labels/tags/model/tag.model.dart';
abstract class LabelRepository {
Future<Correspondent?> getCorrespondent(int id);
Future<List<Correspondent>> getCorrespondents();
Future<Correspondent> saveCorrespondent(Correspondent correspondent);
Future<Correspondent> updateCorrespondent(Correspondent correspondent);
Future<int> deleteCorrespondent(Correspondent correspondent);
Future<Tag?> getTag(int id);
Future<List<Tag>> getTags({List<int>? ids});
Future<Tag> saveTag(Tag tag);
Future<Tag> updateTag(Tag tag);
Future<int> deleteTag(Tag tag);
Future<DocumentType?> getDocumentType(int id);
Future<List<DocumentType>> getDocumentTypes();
Future<DocumentType> saveDocumentType(DocumentType type);
Future<DocumentType> updateDocumentType(DocumentType documentType);
Future<int> deleteDocumentType(DocumentType documentType);
Future<StoragePath?> getStoragePath(int id);
Future<List<StoragePath>> getStoragePaths();
Future<StoragePath> saveStoragePath(StoragePath path);
Future<StoragePath> updateStoragePath(StoragePath path);
Future<int> deleteStoragePath(StoragePath path);
Future<int> getStatistics();
}

View File

@@ -0,0 +1,23 @@
import 'package:flutter_paperless_mobile/core/bloc/label_cubit.dart';
import 'package:flutter_paperless_mobile/features/labels/correspondent/model/correspondent.model.dart';
import 'package:flutter_paperless_mobile/features/labels/storage_path/model/storage_path.model.dart';
import 'package:injectable/injectable.dart';
@singleton
class StoragePathCubit extends LabelCubit<StoragePath> {
StoragePathCubit(super.metaDataService);
@override
Future<void> initialize() async {
return labelRepository.getStoragePaths().then(loadFrom);
}
@override
Future<StoragePath> save(StoragePath item) => labelRepository.saveStoragePath(item);
@override
Future<StoragePath> update(StoragePath item) => labelRepository.updateStoragePath(item);
@override
Future<int> delete(StoragePath item) => labelRepository.deleteStoragePath(item);
}

View File

@@ -0,0 +1,64 @@
import 'package:flutter_paperless_mobile/core/type/json.dart';
import 'package:flutter_paperless_mobile/extensions/dart_extensions.dart';
import 'package:flutter_paperless_mobile/features/labels/document_type/model/matching_algorithm.dart';
import 'package:flutter_paperless_mobile/features/labels/model/label.model.dart';
class StoragePath extends Label {
static const pathKey = 'path';
late String? path;
StoragePath({
required super.id,
required super.name,
super.slug,
super.match,
super.matchingAlgorithm,
super.isInsensitive,
super.documentCount,
required this.path,
});
StoragePath.fromJson(JSON json)
: path = json[pathKey],
super.fromJson(json);
@override
String toString() {
return name;
}
@override
void addSpecificFieldsToJson(JSON json) {
json.tryPutIfAbsent(
pathKey,
() => path,
);
}
@override
StoragePath copyWith({
int? id,
String? name,
String? slug,
String? match,
MatchingAlgorithm? matchingAlgorithm,
bool? isInsensitive,
int? documentCount,
String? path,
}) {
return StoragePath(
id: id ?? this.id,
name: name ?? this.name,
documentCount: documentCount ?? documentCount,
isInsensitive: isInsensitive ?? isInsensitive,
path: path ?? this.path,
match: match ?? this.match,
matchingAlgorithm: matchingAlgorithm ?? this.matchingAlgorithm,
slug: slug ?? this.slug,
);
}
@override
String get queryEndpoint => 'storage_paths';
}

View File

@@ -0,0 +1,26 @@
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:flutter_paperless_mobile/features/labels/storage_path/bloc/storage_path_cubit.dart';
import 'package:flutter_paperless_mobile/features/labels/storage_path/model/storage_path.model.dart';
import 'package:flutter_paperless_mobile/features/labels/storage_path/view/widgets/storage_path_autofill_form_builder_field.dart';
import 'package:flutter_paperless_mobile/features/labels/view/pages/add_label_page.dart';
import 'package:flutter_paperless_mobile/generated/l10n.dart';
class AddStoragePathPage extends StatelessWidget {
final String? initalValue;
const AddStoragePathPage({Key? key, this.initalValue}) : super(key: key);
@override
Widget build(BuildContext context) {
return AddLabelPage<StoragePath>(
addLabelStr: S.of(context).addStoragePathPageTitle,
fromJson: StoragePath.fromJson,
cubit: BlocProvider.of<StoragePathCubit>(context),
initialName: initalValue,
additionalFields: const [
StoragePathAutofillFormBuilderField(name: StoragePath.pathKey),
SizedBox(height: 120.0),
],
);
}
}

Some files were not shown because too many files have changed in this diff Show More