loading state and error handling refactor

This commit is contained in:
2026-05-06 10:11:45 +02:00
parent 2c376afd91
commit 4b1d4379a0
48 changed files with 1377 additions and 354 deletions
+16
View File
@@ -0,0 +1,16 @@
abstract class AppException implements Exception {
final String userMessage;
final String? technicalDetails;
final bool allowRetry;
const AppException({
required this.userMessage,
this.technicalDetails,
this.allowRetry = true,
});
@override
String toString() => technicalDetails == null
? '$runtimeType: $userMessage'
: '$runtimeType: $userMessage ($technicalDetails)';
}
+23
View File
@@ -0,0 +1,23 @@
import 'app_exception.dart';
class AuthException extends AppException {
final int statusCode;
const AuthException({
required this.statusCode,
required super.userMessage,
super.technicalDetails,
}) : super(allowRetry: false);
factory AuthException.unauthorized({String? technicalDetails}) => AuthException(
statusCode: 401,
userMessage: 'Bitte melde dich erneut an, um fortzufahren.',
technicalDetails: technicalDetails,
);
factory AuthException.forbidden({String? technicalDetails}) => AuthException(
statusCode: 403,
userMessage: 'Du hast keine Berechtigung für diese Aktion.',
technicalDetails: technicalDetails,
);
}
+64
View File
@@ -0,0 +1,64 @@
import 'dart:async';
import 'dart:io';
import 'package:http/http.dart' as http;
import '../apiError.dart';
import '../marianumcloud/talk/talkError.dart';
import '../webuntis/webuntisError.dart';
import 'app_exception.dart';
import 'network_exception.dart';
import 'parse_exception.dart';
import 'talk_exception.dart';
import 'webuntis_exception.dart';
const String _defaultFallback = 'Etwas ist schiefgelaufen. Bitte versuche es erneut.';
String errorToUserMessage(Object? error, {String fallback = _defaultFallback}) {
if (error == null) return fallback;
if (error is AppException) return error.userMessage;
if (error is TalkError) return TalkException(error).userMessage;
if (error is WebuntisError) return WebuntisException(error).userMessage;
if (error is SocketException) {
return const NetworkException().userMessage;
}
if (error is TimeoutException) {
return NetworkException.timeout().userMessage;
}
if (error is http.ClientException) {
return const NetworkException().userMessage;
}
if (error is HandshakeException) {
return 'Sichere Verbindung konnte nicht hergestellt werden.';
}
if (error is FormatException) {
return const ParseException().userMessage;
}
if (error is ApiError) {
return _stripDioPrefix(error.message);
}
return fallback;
}
String? errorToTechnicalDetails(Object? error) {
if (error == null) return null;
if (error is AppException) return error.technicalDetails ?? error.toString();
if (error is TalkError) return TalkException(error).technicalDetails;
if (error is WebuntisError) return WebuntisException(error).technicalDetails;
return error.toString();
}
bool errorAllowsRetry(Object? error) {
if (error == null) return true;
if (error is AppException) return error.allowRetry;
return true;
}
String _stripDioPrefix(String raw) {
// ApiError messages embed full request URIs; only surface the first line.
final firstLine = raw.split('\n').first.trim();
return firstLine.isEmpty ? _defaultFallback : firstLine;
}
+13
View File
@@ -0,0 +1,13 @@
import 'app_exception.dart';
class NetworkException extends AppException {
const NetworkException({
super.userMessage = 'Keine Internetverbindung. Bitte prüfe dein Netzwerk und versuche es erneut.',
super.technicalDetails,
}) : super(allowRetry: true);
factory NetworkException.timeout({String? technicalDetails}) => NetworkException(
userMessage: 'Der Server hat zu lange gebraucht. Bitte versuche es erneut.',
technicalDetails: technicalDetails,
);
}
+8
View File
@@ -0,0 +1,8 @@
import 'app_exception.dart';
class NotFoundException extends AppException {
const NotFoundException({
super.userMessage = 'Der angeforderte Eintrag wurde nicht gefunden.',
super.technicalDetails,
}) : super(allowRetry: false);
}
+8
View File
@@ -0,0 +1,8 @@
import 'app_exception.dart';
class ParseException extends AppException {
const ParseException({
super.userMessage = 'Die Antwort des Servers konnte nicht gelesen werden.',
super.technicalDetails,
}) : super(allowRetry: true);
}
+14
View File
@@ -0,0 +1,14 @@
import 'app_exception.dart';
class ServerException extends AppException {
final int statusCode;
ServerException({
required this.statusCode,
String? userMessage,
super.technicalDetails,
}) : super(
userMessage: userMessage ?? 'Der Server hat gerade Probleme (Status $statusCode). Bitte später erneut versuchen.',
allowRetry: true,
);
}
+33
View File
@@ -0,0 +1,33 @@
import '../marianumcloud/talk/talkError.dart';
import 'app_exception.dart';
class TalkException extends AppException {
final TalkError source;
TalkException(this.source)
: super(
userMessage: _mapMessage(source),
technicalDetails: 'Talk ${source.status} (${source.code}): ${source.message}',
allowRetry: source.code >= 500,
);
static String _mapMessage(TalkError e) {
switch (e.code) {
case 401:
return 'Bitte melde dich erneut an, um auf Talk zuzugreifen.';
case 403:
return 'Du hast keine Berechtigung für diese Talk-Aktion.';
case 404:
return 'Dieser Chat existiert nicht oder wurde entfernt.';
case 412:
return 'Diese Aktion ist im aktuellen Chat-Zustand nicht erlaubt.';
case 429:
return 'Zu viele Anfragen. Bitte kurz warten und erneut versuchen.';
default:
if (e.code >= 500) {
return 'Talk-Server hat gerade Probleme (${e.code}).';
}
return e.message.isNotEmpty ? e.message : 'Talk meldet einen Fehler (${e.code}).';
}
}
}
+31
View File
@@ -0,0 +1,31 @@
import '../webuntis/webuntisError.dart';
import 'app_exception.dart';
class WebuntisException extends AppException {
final WebuntisError source;
WebuntisException(this.source)
: super(
userMessage: _mapMessage(source),
technicalDetails: 'WebUntis (${source.code}): ${source.message}',
allowRetry: true,
);
static String _mapMessage(WebuntisError e) {
switch (e.code) {
case -8504:
case -8502:
return 'WebUntis-Anmeldung abgelaufen. Bitte erneut anmelden.';
case -8520:
return 'Bitte melde dich erneut an.';
case -7004:
return 'Für diesen Zeitraum sind keine Stundenplandaten verfügbar.';
case -32601:
return 'WebUntis kennt diese Anfrage nicht. Bitte App aktualisieren.';
default:
return e.message.isNotEmpty
? 'WebUntis: ${e.message}'
: 'WebUntis konnte die Anfrage nicht bearbeiten (Code ${e.code}).';
}
}
}
+32 -13
View File
@@ -1,11 +1,17 @@
import 'dart:async';
import 'dart:developer';
import 'dart:io';
import 'package:http/http.dart' as http;
import '../../apiError.dart';
import '../../apiParams.dart';
import '../../apiRequest.dart';
import '../../apiResponse.dart';
import '../../errors/auth_exception.dart';
import '../../errors/network_exception.dart';
import '../../errors/not_found_exception.dart';
import '../../errors/parse_exception.dart';
import '../../errors/server_exception.dart';
import '../nextcloud_ocs.dart';
enum TalkApiMethod {
@@ -32,16 +38,32 @@ abstract class TalkApi<T extends ApiResponse?> extends ApiRequest {
final endpoint = NextcloudOcs.uri('apps/spreed/api/$path', queryParameters: getParameters);
final mergedHeaders = {...NextcloudOcs.headers(), ...?headers};
http.Response? data;
final http.Response data;
try {
data = await request(endpoint, body, mergedHeaders);
if (data == null) throw Exception('No response Data');
if (data.statusCode >= 400 || data.statusCode < 200) {
throw Exception("Response status code '${data.statusCode}' might indicate an error");
final raw = await request(endpoint, body, mergedHeaders);
if (raw == null) {
throw const NetworkException(
userMessage: 'Keine Antwort vom Talk-Server erhalten.',
technicalDetails: 'Talk request returned null',
);
}
} catch (e) {
log(e.toString());
throw ApiError('Request $endpoint could not be dispatched: ${e.toString()}');
data = raw;
} on SocketException catch (e) {
throw NetworkException(technicalDetails: 'Talk $endpoint: ${e.message}');
} on TimeoutException catch (e) {
throw NetworkException.timeout(technicalDetails: 'Talk $endpoint: $e');
} on http.ClientException catch (e) {
throw NetworkException(technicalDetails: 'Talk $endpoint: ${e.message}');
}
final status = data.statusCode;
if (status < 200 || status >= 300) {
final detail = 'Talk $endpoint -> HTTP $status';
log(detail);
if (status == 401) throw AuthException.unauthorized(technicalDetails: detail);
if (status == 403) throw AuthException.forbidden(technicalDetails: detail);
if (status == 404) throw NotFoundException(technicalDetails: detail);
throw ServerException(statusCode: status, technicalDetails: detail);
}
try {
@@ -49,10 +71,7 @@ abstract class TalkApi<T extends ApiResponse?> extends ApiRequest {
assembled?.headers = data.headers;
return assembled;
} catch (e) {
final message = 'Error assembling Talk API ${T.toString()} message: ${e.toString()}'
' response with request body: $body and request headers: $mergedHeaders';
log(message);
throw Exception(message);
throw ParseException(technicalDetails: 'Talk $endpoint assemble: $e');
}
}
}
+33 -8
View File
@@ -1,9 +1,14 @@
import 'dart:async';
import 'dart:convert';
import 'dart:io';
import 'package:http/http.dart' as http;
import 'package:jiffy/jiffy.dart';
import '../apiError.dart';
import '../apiRequest.dart';
import '../errors/network_exception.dart';
import '../errors/parse_exception.dart';
import '../errors/server_exception.dart';
abstract class MhslApi<T> extends ApiRequest {
String subpath;
@@ -15,18 +20,38 @@ abstract class MhslApi<T> extends ApiRequest {
T assemble(String raw);
Future<T> run() async {
var endpoint = Uri.parse('https://mhsl.eu/marianum/marianummobile/$subpath');
final endpoint = Uri.parse('https://mhsl.eu/marianum/marianummobile/$subpath');
var data = await request(endpoint);
if(data == null) {
throw ApiError('Request could not be dispatched!');
final http.Response data;
try {
final raw = await request(endpoint);
if (raw == null) {
throw const NetworkException(
userMessage: 'Keine Antwort vom MHSL-Dienst erhalten.',
technicalDetails: 'mhsl request returned null',
);
}
data = raw;
} on SocketException catch (e) {
throw NetworkException(technicalDetails: 'mhsl $subpath: ${e.message}');
} on TimeoutException catch (e) {
throw NetworkException.timeout(technicalDetails: 'mhsl $subpath: $e');
} on http.ClientException catch (e) {
throw NetworkException(technicalDetails: 'mhsl $subpath: ${e.message}');
}
if(data.statusCode > 299) {
throw ApiError('Non 200 Status code from mhsl services: $subpath: ${data.statusCode}');
if (data.statusCode > 299) {
throw ServerException(
statusCode: data.statusCode,
technicalDetails: 'mhsl $subpath HTTP ${data.statusCode}',
);
}
return assemble(utf8.decode(data.bodyBytes));
try {
return assemble(utf8.decode(data.bodyBytes));
} catch (e) {
throw ParseException(technicalDetails: 'mhsl $subpath assemble: $e');
}
}
static String dateTimeToJson(DateTime time) => Jiffy.parseFromDateTime(time).format(pattern: 'yyyy-MM-dd HH:mm:ss');
+24 -8
View File
@@ -1,10 +1,15 @@
import 'dart:async';
import 'dart:convert';
import 'dart:io';
import 'package:http/http.dart' as http;
import '../../model/endpoint_data.dart';
import '../apiParams.dart';
import '../apiRequest.dart';
import '../apiResponse.dart';
import '../errors/network_exception.dart';
import '../errors/parse_exception.dart';
import 'queries/authenticate/authenticate.dart';
import 'webuntisError.dart';
@@ -29,10 +34,15 @@ abstract class WebuntisApi extends ApiRequest {
var data = await post(query, {'Cookie': 'JSESSIONID=$sessionId'});
response = data;
dynamic jsonData = jsonDecode(data.body);
final dynamic jsonData;
try {
jsonData = jsonDecode(data.body);
} on FormatException catch (e) {
throw ParseException(technicalDetails: 'WebUntis JSON decode: ${e.message}');
}
if(jsonData['error'] != null) {
if(jsonData['error']['code'] == -8520) {
if(retry) throw WebuntisError('Authentication was tried (probably session timeout), but was not successful!', 1);
if(retry) throw WebuntisError('Authentication was tried (probably session timeout), but was not successful!', -8520);
await Authenticate.createSession();
return await this.query(untis, retry: true);
} else {
@@ -51,10 +61,16 @@ abstract class WebuntisApi extends ApiRequest {
String _body() => genericParam == null ? '{}' : jsonEncode(genericParam);
Future<http.Response> post(String data, Map<String, String>? headers) async => await http
.post(endpoint, body: data, headers: headers)
.timeout(
const Duration(seconds: 10),
onTimeout: () => throw WebuntisError('Timeout', 1)
);
Future<http.Response> post(String data, Map<String, String>? headers) async {
try {
return await http.post(endpoint, body: data, headers: headers).timeout(
const Duration(seconds: 10),
onTimeout: () => throw NetworkException.timeout(technicalDetails: 'WebUntis $method timed out after 10s'),
);
} on SocketException catch (e) {
throw NetworkException(technicalDetails: 'WebUntis $method: ${e.message}');
} on http.ClientException catch (e) {
throw NetworkException(technicalDetails: 'WebUntis $method: ${e.message}');
}
}
}