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,
),
);
}
}