Updated onboarding, reformatted files, improved referenced documents view, updated error handling

This commit is contained in:
Anton Stubenbord
2022-11-03 22:15:36 +01:00
parent 2f2312d5f3
commit 40133b6e0e
117 changed files with 1788 additions and 1021 deletions

View File

@@ -9,13 +9,20 @@ class ConnectivityCubit extends Cubit<ConnectivityState> {
final ConnectivityStatusService connectivityStatusService;
late final StreamSubscription<bool> _sub;
ConnectivityCubit(this.connectivityStatusService) : super(ConnectivityState.undefined);
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);
final bool isConnected =
await connectivityStatusService.isConnectedToInternet();
emit(isConnected
? ConnectivityState.connected
: ConnectivityState.notConnected);
_sub =
connectivityStatusService.connectivityChanges().listen((isConnected) {
emit(isConnected
? ConnectivityState.connected
: ConnectivityState.notConnected);
});
}

View File

@@ -0,0 +1,46 @@
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:injectable/injectable.dart';
import 'package:paperless_mobile/core/model/error_message.dart';
///
/// Class for handling generic errors which usually only require to inform the user via a Snackbar
/// or similar that an error has occurred.
///
@singleton
class GlobalErrorCubit extends Cubit<GlobalErrorState> {
static const _waitBeforeNextErrorDuration = Duration(seconds: 5);
GlobalErrorCubit() : super(GlobalErrorState.initial);
///
/// Adds a new error to this bloc. If the new error is equal to the current error, the new error
/// will not be published unless the previous error occured over 5 seconds ago.
///
void add(ErrorMessage error) {
final now = DateTime.now();
if (error != state.error || (error == state.error && _canEmitNewError())) {
emit(GlobalErrorState(error: error, errorTimestamp: now));
}
}
bool _canEmitNewError() {
if (state.errorTimestamp != null) {
return DateTime.now().difference(state.errorTimestamp!).inSeconds >= 5;
}
return true;
}
void reset() {
emit(GlobalErrorState.initial);
}
}
class GlobalErrorState {
static const GlobalErrorState initial = GlobalErrorState();
final ErrorMessage? error;
final DateTime? errorTimestamp;
const GlobalErrorState({this.error, this.errorTimestamp});
bool get hasError => error != null;
}

View File

@@ -1,40 +1,75 @@
import 'package:flutter/foundation.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:paperless_mobile/core/bloc/global_error_cubit.dart';
import 'package:paperless_mobile/core/model/error_message.dart';
import 'package:paperless_mobile/features/labels/model/label.model.dart';
import 'package:paperless_mobile/features/labels/repository/label_repository.dart';
abstract class LabelCubit<T extends Label> extends Cubit<Map<int, T>> {
final LabelRepository labelRepository;
LabelCubit(this.labelRepository) : super({});
final GlobalErrorCubit errorCubit;
LabelCubit(this.labelRepository, this.errorCubit) : super({});
@protected
void loadFrom(Iterable<T> items) => emit(Map.fromIterable(items, key: (e) => (e as T).id!));
void loadFrom(Iterable<T> items) =>
emit(Map.fromIterable(items, key: (e) => (e as T).id!));
Future<T> add(T item) async {
Future<T> add(
T item, {
bool propagateEventOnError = true,
}) async {
assert(item.id == null);
final addedItem = await save(item);
final newState = {...state};
newState.putIfAbsent(addedItem.id!, () => addedItem);
emit(newState);
return addedItem;
try {
final addedItem = await save(item);
final newState = {...state};
newState.putIfAbsent(addedItem.id!, () => addedItem);
emit(newState);
return addedItem;
} on ErrorMessage catch (error) {
if (propagateEventOnError) {
errorCubit.add(error);
}
return Future.error(error);
}
}
Future<T> replace(T item) async {
Future<T> replace(
T item, {
bool propagateEventOnError = true,
}) async {
assert(item.id != null);
final updatedItem = await update(item);
final newState = {...state};
newState[item.id!] = updatedItem;
emit(newState);
return updatedItem;
try {
final updatedItem = await update(item);
final newState = {...state};
newState[item.id!] = updatedItem;
emit(newState);
return updatedItem;
} on ErrorMessage catch (error) {
if (propagateEventOnError) {
errorCubit.add(error);
}
return Future.error(error);
}
}
Future<void> remove(T item) async {
Future<void> remove(
T item, {
bool propagateEventOnError = true,
}) async {
assert(item.id != null);
if (state.containsKey(item.id)) {
final deletedId = await delete(item);
final newState = {...state};
newState.remove(deletedId);
emit(newState);
try {
final deletedId = await delete(item);
final newState = {...state};
newState.remove(deletedId);
emit(newState);
} on ErrorMessage catch (error) {
if (propagateEventOnError) {
errorCubit.add(error);
}
return Future.error(error);
}
}
}

View File

@@ -22,13 +22,19 @@ class AuthenticationInterceptor implements InterceptorContract {
}
return request.copyWith(
//Append server Url
url: Uri.parse(authState.authentication!.serverUrl + request.url.toString()),
url: Uri.parse(
authState.authentication!.serverUrl + request.url.toString()),
headers: authState.authentication!.token.isEmpty
? request.headers
: {...request.headers, 'Authorization': 'Token ${authState.authentication!.token}'},
: {
...request.headers,
'Authorization': 'Token ${authState.authentication!.token}'
},
);
}
@override
Future<BaseResponse> interceptResponse({required BaseResponse response}) async => response;
Future<BaseResponse> interceptResponse(
{required BaseResponse response}) async =>
response;
}

View File

@@ -4,24 +4,22 @@ import 'package:paperless_mobile/features/login/bloc/authentication_cubit.dart';
import 'package:http_interceptor/http_interceptor.dart';
import 'package:injectable/injectable.dart';
@Deprecated("Delegated to TimeoutClient!")
@injectable
class ConnectionStateInterceptor implements InterceptorContract {
final AuthenticationCubit authenticationCubit;
final ConnectivityStatusService connectivityStatusService;
ConnectionStateInterceptor(this.authenticationCubit, this.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;
Future<BaseResponse> interceptResponse(
{required BaseResponse response}) async =>
response;
}

View File

@@ -14,12 +14,15 @@ class LanguageHeaderInterceptor implements InterceptorContract {
if (appSettingsCubit.state.preferredLocaleSubtag == "en") {
languages = "en";
} else {
languages = appSettingsCubit.state.preferredLocaleSubtag + ",en;q=0.7,en-US;q=0.6";
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;
Future<BaseResponse> interceptResponse(
{required BaseResponse response}) async =>
response;
}

View File

@@ -7,11 +7,14 @@ const interceptedRoutes = ['thumb/'];
@injectable
class ResponseConversionInterceptor implements InterceptorContract {
@override
Future<BaseRequest> interceptRequest({required BaseRequest request}) async => request;
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 ?? '';
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;

View File

@@ -3,7 +3,8 @@ import 'dart:typed_data';
import 'dart:convert';
import 'package:paperless_mobile/core/model/error_message.dart';
import 'package:paperless_mobile/core/type/json.dart';
import 'package:paperless_mobile/core/service/connectivity_status.service.dart';
import 'package:paperless_mobile/core/type/types.dart';
import 'package:paperless_mobile/di_initializer.dart';
import 'package:http/http.dart';
import 'package:injectable/injectable.dart';
@@ -14,13 +15,17 @@ import 'package:injectable/injectable.dart';
@Injectable(as: BaseClient)
@Named("timeoutClient")
class TimeoutClient implements BaseClient {
final ConnectivityStatusService connectivityStatusService;
static const Duration requestTimeout = Duration(seconds: 25);
TimeoutClient(this.connectivityStatusService);
@override
Future<StreamedResponse> send(BaseRequest request) async {
return getIt<BaseClient>().send(request).timeout(
requestTimeout,
onTimeout: () => Future.error(const ErrorMessage(ErrorCode.requestTimedOut)),
onTimeout: () =>
Future.error(const ErrorMessage(ErrorCode.requestTimedOut)),
);
}
@@ -32,32 +37,38 @@ class TimeoutClient implements BaseClient {
@override
Future<Response> delete(Uri url,
{Map<String, String>? headers, Object? body, Encoding? encoding}) async {
await _handleOfflineState();
return _handle400Error(
await getIt<BaseClient>()
.delete(url, headers: headers, body: body, encoding: encoding)
.timeout(
requestTimeout,
onTimeout: () => Future.error(const ErrorMessage(ErrorCode.requestTimedOut)),
onTimeout: () =>
Future.error(const ErrorMessage(ErrorCode.requestTimedOut)),
),
);
}
@override
Future<Response> get(Uri url, {Map<String, String>? headers}) async {
await _handleOfflineState();
return _handle400Error(
await getIt<BaseClient>().get(url, headers: headers).timeout(
requestTimeout,
onTimeout: () => Future.error(const ErrorMessage(ErrorCode.requestTimedOut)),
onTimeout: () =>
Future.error(const ErrorMessage(ErrorCode.requestTimedOut)),
),
);
}
@override
Future<Response> head(Uri url, {Map<String, String>? headers}) async {
await _handleOfflineState();
return _handle400Error(
await getIt<BaseClient>().head(url, headers: headers).timeout(
requestTimeout,
onTimeout: () => Future.error(const ErrorMessage(ErrorCode.requestTimedOut)),
onTimeout: () =>
Future.error(const ErrorMessage(ErrorCode.requestTimedOut)),
),
);
}
@@ -65,12 +76,14 @@ class TimeoutClient implements BaseClient {
@override
Future<Response> patch(Uri url,
{Map<String, String>? headers, Object? body, Encoding? encoding}) async {
await _handleOfflineState();
return _handle400Error(
await getIt<BaseClient>()
.patch(url, headers: headers, body: body, encoding: encoding)
.timeout(
requestTimeout,
onTimeout: () => Future.error(const ErrorMessage(ErrorCode.requestTimedOut)),
onTimeout: () =>
Future.error(const ErrorMessage(ErrorCode.requestTimedOut)),
),
);
}
@@ -78,10 +91,14 @@ class TimeoutClient implements BaseClient {
@override
Future<Response> post(Uri url,
{Map<String, String>? headers, Object? body, Encoding? encoding}) async {
await _handleOfflineState();
return _handle400Error(
await getIt<BaseClient>().post(url, headers: headers, body: body, encoding: encoding).timeout(
await getIt<BaseClient>()
.post(url, headers: headers, body: body, encoding: encoding)
.timeout(
requestTimeout,
onTimeout: () => Future.error(const ErrorMessage(ErrorCode.requestTimedOut)),
onTimeout: () =>
Future.error(const ErrorMessage(ErrorCode.requestTimedOut)),
),
);
}
@@ -89,27 +106,35 @@ class TimeoutClient implements BaseClient {
@override
Future<Response> put(Uri url,
{Map<String, String>? headers, Object? body, Encoding? encoding}) async {
await _handleOfflineState();
return _handle400Error(
await getIt<BaseClient>().put(url, headers: headers, body: body, encoding: encoding).timeout(
await getIt<BaseClient>()
.put(url, headers: headers, body: body, encoding: encoding)
.timeout(
requestTimeout,
onTimeout: () => Future.error(const ErrorMessage(ErrorCode.requestTimedOut)),
onTimeout: () =>
Future.error(const ErrorMessage(ErrorCode.requestTimedOut)),
),
);
}
@override
Future<String> read(Uri url, {Map<String, String>? headers}) async {
await _handleOfflineState();
return getIt<BaseClient>().read(url, headers: headers).timeout(
requestTimeout,
onTimeout: () => Future.error(const ErrorMessage(ErrorCode.requestTimedOut)),
onTimeout: () =>
Future.error(const ErrorMessage(ErrorCode.requestTimedOut)),
);
}
@override
Future<Uint8List> readBytes(Uri url, {Map<String, String>? headers}) {
Future<Uint8List> readBytes(Uri url, {Map<String, String>? headers}) async {
await _handleOfflineState();
return getIt<BaseClient>().readBytes(url, headers: headers).timeout(
requestTimeout,
onTimeout: () => Future.error(const ErrorMessage(ErrorCode.requestTimedOut)),
onTimeout: () =>
Future.error(const ErrorMessage(ErrorCode.requestTimedOut)),
);
}
@@ -117,11 +142,12 @@ class TimeoutClient implements BaseClient {
if (response.statusCode == 400) {
// try to parse contained error message, otherwise return response
final JSON json = jsonDecode(utf8.decode(response.bodyBytes));
final Map<String, String> errorMessages = {};
final PaperlessValidationErrors 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);
errorMessages.putIfAbsent(
entry.key, () => (entry.value as List).cast<String>().first);
} else if (entry.value is String) {
errorMessages.putIfAbsent(entry.key, () => entry.value);
} else {
@@ -132,4 +158,10 @@ class TimeoutClient implements BaseClient {
}
return response;
}
Future<void> _handleOfflineState() async {
if (!(await connectivityStatusService.isConnectedToInternet())) {
throw const ErrorMessage(ErrorCode.deviceOffline);
}
}
}

View File

@@ -17,12 +17,15 @@ class ConnectivityStatusServiceImpl implements ConnectivityStatusService {
@override
Stream<bool> connectivityChanges() {
return connectivity.onConnectivityChanged.map(_hasActiveInternetConnection).asBroadcastStream();
return connectivity.onConnectivityChanged
.map(_hasActiveInternetConnection)
.asBroadcastStream();
}
@override
Future<bool> isConnectedToInternet() async {
return _hasActiveInternetConnection(await (Connectivity().checkConnectivity()));
return _hasActiveInternetConnection(
await (Connectivity().checkConnectivity()));
}
@override

View File

@@ -13,8 +13,8 @@ import 'package:injectable/injectable.dart';
import 'package:web_socket_channel/io.dart';
abstract class StatusService {
Future<void> startListeningBeforeDocumentUpload(
String httpUrl, AuthenticationInformation credentials, String documentFileName);
Future<void> startListeningBeforeDocumentUpload(String httpUrl,
AuthenticationInformation credentials, String documentFileName);
}
@Singleton(as: StatusService)
@@ -86,9 +86,11 @@ class LongPollingStatusService implements StatusService {
do {
final response = await httpClient.get(
Uri.parse('$httpUrl/api/documents/?query=$documentFileName added:${formatDate(today)}'),
Uri.parse(
'$httpUrl/api/documents/?query=$documentFileName added:${formatDate(today)}'),
);
final data = PagedSearchResult.fromJson(jsonDecode(response.body), DocumentModel.fromJson);
final data = PagedSearchResult.fromJson(
jsonDecode(response.body), DocumentModel.fromJson);
if (data.count > 0) {
consumptionFinished = true;
final docId = data.results[0].id;

View File

@@ -34,11 +34,13 @@ class LocalVault {
}
Future<ClientCertificate?> loadCertificate() async {
return loadAuthenticationInformation().then((value) => value?.clientCertificate);
return loadAuthenticationInformation()
.then((value) => value?.clientCertificate);
}
Future<bool> storeApplicationSettings(ApplicationSettingsState settings) {
return sharedPreferences.setString(applicationSettingsKey, json.encode(settings.toJson()));
return sharedPreferences.setString(
applicationSettingsKey, json.encode(settings.toJson()));
}
Future<ApplicationSettingsState?> loadApplicationSettings() async {

View File

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

2
lib/core/type/types.dart Normal file
View File

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

View File

@@ -4,7 +4,7 @@ import 'dart:typed_data';
import 'package:paperless_mobile/core/logic/timeout_client.dart';
import 'package:paperless_mobile/core/model/error_message.dart';
import 'package:paperless_mobile/core/type/json.dart';
import 'package:paperless_mobile/core/type/types.dart';
import 'package:paperless_mobile/di_initializer.dart';
import 'package:http/http.dart';
import 'package:path_provider/path_provider.dart';
@@ -45,7 +45,10 @@ Future<List<T>> getCollection<T>(
if (body['count'] == 0) {
return <T>[];
} else {
return body['results'].cast<JSON>().map<T>((result) => fromJson(result)).toList();
return body['results']
.cast<JSON>()
.map<T>((result) => fromJson(result))
.toList();
}
}
}

View File

@@ -5,9 +5,12 @@ import 'package:flutter/material.dart';
class ElevatedConfirmationButton extends StatefulWidget {
factory ElevatedConfirmationButton.icon(BuildContext context,
{required void Function() onPressed, required Icon icon, required Widget label}) {
{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))!;
final double gap =
scale <= 1 ? 8 : lerpDouble(8, 4, math.min(scale - 1, 1))!;
return ElevatedConfirmationButton(
child: Row(
mainAxisSize: MainAxisSize.min,
@@ -30,10 +33,12 @@ class ElevatedConfirmationButton extends StatefulWidget {
final Widget child;
final Widget confirmWidget;
@override
State<ElevatedConfirmationButton> createState() => _ElevatedConfirmationButtonState();
State<ElevatedConfirmationButton> createState() =>
_ElevatedConfirmationButtonState();
}
class _ElevatedConfirmationButtonState extends State<ElevatedConfirmationButton> {
class _ElevatedConfirmationButtonState
extends State<ElevatedConfirmationButton> {
bool _clickedOnce = false;
double? _originalWidth;
final GlobalKey _originalWidgetKey = GlobalKey();
@@ -46,8 +51,10 @@ class _ElevatedConfirmationButtonState extends State<ElevatedConfirmationButton>
backgroundColor: MaterialStateProperty.all(widget.color),
),
onPressed: () {
_originalWidth =
(_originalWidgetKey.currentContext?.findRenderObject() as RenderBox).size.width;
_originalWidth = (_originalWidgetKey.currentContext
?.findRenderObject() as RenderBox)
.size
.width;
setState(() => _clickedOnce = true);
},
child: widget.child,

View File

@@ -38,10 +38,13 @@ class DocumentsListLoadingWidget extends StatelessWidget {
titleLengths[r.nextInt(titleLengths.length - 1)];
return ListTile(
isThreeLine: true,
leading: Container(
color: Colors.white,
height: 50,
width: 50,
leading: ClipRRect(
borderRadius: BorderRadius.circular(8),
child: Container(
color: Colors.white,
height: 50,
width: 35,
),
),
title: Container(
padding: const EdgeInsets.symmetric(vertical: 2.0),
@@ -65,7 +68,7 @@ class DocumentsListLoadingWidget extends StatelessWidget {
spacing: 2.0,
children: List.generate(
tagCount,
(index) => Chip(
(index) => InputChip(
label: Text(tags[r.nextInt(tags.length)]),
),
),

View File

@@ -71,8 +71,9 @@ class HighlightedText extends StatelessWidget {
int _start = 0;
String _text = caseSensitive ? text : text.toLowerCase();
List<String> _highlights =
caseSensitive ? highlights : highlights.map((e) => e.toLowerCase()).toList();
List<String> _highlights = caseSensitive
? highlights
: highlights.map((e) => e.toLowerCase()).toList();
while (true) {
Map<int, String> _highlightsMap = {}; //key (index), value (highlight).
@@ -95,7 +96,8 @@ class HighlightedText extends StatelessWidget {
_spans.add(_highlightSpan(_currentHighlight));
_start += _currentHighlight.length;
} else {
_spans.add(_normalSpan(text.substring(_start, _currentIndex), context));
_spans
.add(_normalSpan(text.substring(_start, _currentIndex), context));
_spans.add(_highlightSpan(_currentHighlight));
_start = _currentIndex + _currentHighlight.length;
}

View File

@@ -11,7 +11,9 @@ class OfflineWidget extends StatelessWidget {
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(Icons.mood_bad, size: (Theme.of(context).iconTheme.size ?? 24) * 3),
Icon(Icons.wifi_off,
color: Theme.of(context).disabledColor,
size: (Theme.of(context).iconTheme.size ?? 24) * 3),
Text(
S.of(context).offlineWidgetText,
textAlign: TextAlign.center,