mirror of
https://github.com/Xevion/paperless-mobile.git
synced 2025-12-10 18:07:59 -06:00
Initial commit
This commit is contained in:
29
lib/core/bloc/connectivity_cubit.dart
Normal file
29
lib/core/bloc/connectivity_cubit.dart
Normal 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 }
|
||||
10
lib/core/bloc/document_status_cubit.dart
Normal file
10
lib/core/bloc/document_status_cubit.dart
Normal 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);
|
||||
}
|
||||
27
lib/core/bloc/label_bloc_provider.dart
Normal file
27
lib/core/bloc/label_bloc_provider.dart
Normal 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,
|
||||
);
|
||||
}
|
||||
}
|
||||
53
lib/core/bloc/label_cubit.dart
Normal file
53
lib/core/bloc/label_cubit.dart
Normal 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);
|
||||
}
|
||||
11
lib/core/global/http_self_signed_certificate_override.dart
Normal file
11
lib/core/global/http_self_signed_certificate_override.dart
Normal 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;
|
||||
}
|
||||
}
|
||||
34
lib/core/interceptor/authentication.interceptor.dart
Normal file
34
lib/core/interceptor/authentication.interceptor.dart
Normal 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;
|
||||
}
|
||||
31
lib/core/interceptor/connection_state.interceptor.dart
Normal file
31
lib/core/interceptor/connection_state.interceptor.dart
Normal 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;
|
||||
}
|
||||
25
lib/core/interceptor/language_header.interceptor.dart
Normal file
25
lib/core/interceptor/language_header.interceptor.dart
Normal 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;
|
||||
}
|
||||
32
lib/core/interceptor/response_conversion.interceptor.dart
Normal file
32
lib/core/interceptor/response_conversion.interceptor.dart
Normal 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;
|
||||
}
|
||||
}
|
||||
70
lib/core/logic/error_code_localization_mapper.dart
Normal file
70
lib/core/logic/error_code_localization_mapper.dart
Normal 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;
|
||||
}
|
||||
}
|
||||
135
lib/core/logic/timeout_client.dart
Normal file
135
lib/core/logic/timeout_client.dart
Normal 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;
|
||||
}
|
||||
}
|
||||
46
lib/core/model/document_processing_status.dart
Normal file
46
lib/core/model/document_processing_status.dart
Normal 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'],
|
||||
);
|
||||
}
|
||||
}
|
||||
50
lib/core/model/error_message.dart
Normal file
50
lib/core/model/error_message.dart
Normal 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;
|
||||
}
|
||||
51
lib/core/service/connectivity_status.service.dart
Normal file
51
lib/core/service/connectivity_status.service.dart
Normal 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;
|
||||
}
|
||||
}
|
||||
112
lib/core/service/status.service.dart
Normal file
112
lib/core/service/status.service.dart
Normal 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);
|
||||
}
|
||||
}
|
||||
55
lib/core/store/local_vault.dart
Normal file
55
lib/core/store/local_vault.dart
Normal 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
1
lib/core/type/json.dart
Normal file
@@ -0,0 +1 @@
|
||||
typedef JSON = Map<String, dynamic>;
|
||||
66
lib/core/util.dart
Normal file
66
lib/core/util.dart
Normal 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;
|
||||
}
|
||||
}
|
||||
15
lib/core/widgets/coming_soon_placeholder.dart
Normal file
15
lib/core/widgets/coming_soon_placeholder.dart
Normal 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,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
70
lib/core/widgets/confirm_button.dart
Normal file
70
lib/core/widgets/confirm_button.dart
Normal 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,
|
||||
),
|
||||
);
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
86
lib/core/widgets/documents_list_loading_widget.dart
Normal file
86
lib/core/widgets/documents_list_loading_widget.dart
Normal 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,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
45
lib/core/widgets/empty_state.dart
Normal file
45
lib/core/widgets/empty_state.dart
Normal 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 ...[]
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
215
lib/core/widgets/expandable_floating_action_button.dart
Normal file
215
lib/core/widgets/expandable_floating_action_button.dart
Normal 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),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
125
lib/core/widgets/highlighted_text.dart
Normal file
125
lib/core/widgets/highlighted_text.dart
Normal 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,
|
||||
);
|
||||
}
|
||||
}
|
||||
30
lib/core/widgets/offline_banner.dart
Normal file
30
lib/core/widgets/offline_banner.dart
Normal 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);
|
||||
}
|
||||
23
lib/core/widgets/offline_widget.dart
Normal file
23
lib/core/widgets/offline_widget.dart
Normal 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,
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
23
lib/core/widgets/paperless_logo.dart
Normal file
23
lib/core/widgets/paperless_logo.dart
Normal 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,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user