refactored data providers with centralized cache resolution, unified UI using custom dialogs and bottom sheets, and enhanced network error handling for Dio and TLS errors

This commit is contained in:
2026-05-08 20:01:45 +02:00
parent c62a14645a
commit 9e139b5704
37 changed files with 595 additions and 753 deletions
+51 -1
View File
@@ -1,6 +1,7 @@
import 'dart:async';
import 'dart:io';
import 'package:dio/dio.dart';
import 'package:http/http.dart' as http;
import '../api_error.dart';
@@ -9,10 +10,46 @@ import '../webuntis/webuntis_error.dart';
import 'app_exception.dart';
import 'network_exception.dart';
import 'parse_exception.dart';
import 'server_exception.dart';
import 'talk_exception.dart';
import 'webuntis_exception.dart';
const String _defaultFallback = 'Etwas ist schiefgelaufen. Bitte versuche es erneut.';
const String _tlsErrorMessage =
'Die sichere Verbindung zum Server wurde abgelehnt (Zertifikat oder TLS-Fehler). '
'Häufige Ursachen: falsche Geräte-Uhrzeit oder ein WLAN mit Anmeldeseite (z.B. Café/Hotel).';
AppException? _dioToAppException(DioException error) {
switch (error.type) {
case DioExceptionType.connectionTimeout:
case DioExceptionType.sendTimeout:
case DioExceptionType.receiveTimeout:
return NetworkException.timeout(technicalDetails: error.message);
case DioExceptionType.connectionError:
return NetworkException(technicalDetails: error.message);
case DioExceptionType.badCertificate:
return const NetworkException(
userMessage: _tlsErrorMessage,
);
case DioExceptionType.badResponse:
final status = error.response?.statusCode;
return ServerException(
statusCode: status ?? -1,
technicalDetails: 'HTTP $status: ${error.message}',
);
case DioExceptionType.cancel:
case DioExceptionType.unknown:
final inner = error.error;
if (inner is SocketException) return NetworkException(technicalDetails: inner.message);
if (inner is HandshakeException) {
return const NetworkException(
userMessage: _tlsErrorMessage,
);
}
if (inner is FormatException) return ParseException(technicalDetails: inner.message);
return null;
}
}
String errorToUserMessage(Object? error, {String fallback = _defaultFallback}) {
if (error == null) return fallback;
@@ -21,6 +58,11 @@ String errorToUserMessage(Object? error, {String fallback = _defaultFallback}) {
if (error is TalkError) return TalkException(error).userMessage;
if (error is WebuntisError) return WebuntisException(error).userMessage;
if (error is DioException) {
final mapped = _dioToAppException(error);
if (mapped != null) return mapped.userMessage;
}
if (error is SocketException) {
return const NetworkException().userMessage;
}
@@ -31,7 +73,7 @@ String errorToUserMessage(Object? error, {String fallback = _defaultFallback}) {
return const NetworkException().userMessage;
}
if (error is HandshakeException) {
return 'Sichere Verbindung konnte nicht hergestellt werden.';
return _tlsErrorMessage;
}
if (error is FormatException) {
return const ParseException().userMessage;
@@ -48,12 +90,20 @@ String? errorToTechnicalDetails(Object? error) {
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;
if (error is DioException) {
final mapped = _dioToAppException(error);
if (mapped != null) return mapped.technicalDetails ?? mapped.toString();
}
return error.toString();
}
bool errorAllowsRetry(Object? error) {
if (error == null) return true;
if (error is AppException) return error.allowRetry;
if (error is DioException) {
final mapped = _dioToAppException(error);
if (mapped != null) return mapped.allowRetry;
}
return true;
}
-16
View File
@@ -1,16 +0,0 @@
import 'dart:convert';
import 'package:http/http.dart' as http;
import 'get_holidays_response.dart';
class GetHolidays {
Future<GetHolidaysResponse> query() async {
var response = (await http.get(Uri.parse('https://ferien-api.de/api/v1/holidays/HE'))).body;
var data = jsonDecode(response) as List<dynamic>;
return GetHolidaysResponse(
List<GetHolidaysResponseObject>.from(
data.map<GetHolidaysResponseObject>((e) => GetHolidaysResponseObject.fromJson(e as Map<String, dynamic>))
)
);
}
}
-18
View File
@@ -1,18 +0,0 @@
import '../request_cache.dart';
import 'get_holidays.dart';
import 'get_holidays_response.dart';
class GetHolidaysCache extends SimpleCache<GetHolidaysResponse> {
GetHolidaysCache({super.onUpdate, super.renew})
: super(
cacheTime: RequestCache.cacheDay,
loader: () => GetHolidays().query(),
fromJson: (json) => GetHolidaysResponse(
(json['data'] as List)
.map((i) => GetHolidaysResponseObject.fromJson(i as Map<String, dynamic>))
.toList(),
),
) {
start('state-holidays');
}
}
@@ -1,38 +0,0 @@
import 'package:json_annotation/json_annotation.dart';
import '../api_response.dart';
part 'get_holidays_response.g.dart';
@JsonSerializable(explicitToJson: true)
class GetHolidaysResponse extends ApiResponse {
List<GetHolidaysResponseObject> data;
GetHolidaysResponse(this.data);
factory GetHolidaysResponse.fromJson(Map<String, dynamic> json) => _$GetHolidaysResponseFromJson(json);
Map<String, dynamic> toJson() => _$GetHolidaysResponseToJson(this);
}
@JsonSerializable()
class GetHolidaysResponseObject {
String start;
String end;
int year;
String stateCode;
String name;
String slug;
GetHolidaysResponseObject({
required this.start,
required this.end,
required this.year,
required this.stateCode,
required this.name,
required this.slug
});
factory GetHolidaysResponseObject.fromJson(Map<String, dynamic> json) => _$GetHolidaysResponseObjectFromJson(json);
Map<String, dynamic> toJson() => _$GetHolidaysResponseObjectToJson(this);
}
@@ -1,49 +0,0 @@
// GENERATED CODE - DO NOT MODIFY BY HAND
part of 'get_holidays_response.dart';
// **************************************************************************
// JsonSerializableGenerator
// **************************************************************************
GetHolidaysResponse _$GetHolidaysResponseFromJson(Map<String, dynamic> json) =>
GetHolidaysResponse(
(json['data'] as List<dynamic>)
.map(
(e) =>
GetHolidaysResponseObject.fromJson(e as Map<String, dynamic>),
)
.toList(),
)
..headers = (json['headers'] as Map<String, dynamic>?)?.map(
(k, e) => MapEntry(k, e as String),
);
Map<String, dynamic> _$GetHolidaysResponseToJson(
GetHolidaysResponse instance,
) => <String, dynamic>{
'headers': ?instance.headers,
'data': instance.data.map((e) => e.toJson()).toList(),
};
GetHolidaysResponseObject _$GetHolidaysResponseObjectFromJson(
Map<String, dynamic> json,
) => GetHolidaysResponseObject(
start: json['start'] as String,
end: json['end'] as String,
year: (json['year'] as num).toInt(),
stateCode: json['stateCode'] as String,
name: json['name'] as String,
slug: json['slug'] as String,
);
Map<String, dynamic> _$GetHolidaysResponseObjectToJson(
GetHolidaysResponseObject instance,
) => <String, dynamic>{
'start': instance.start,
'end': instance.end,
'year': instance.year,
'stateCode': instance.stateCode,
'name': instance.name,
'slug': instance.slug,
};
+2 -12
View File
@@ -1,27 +1,17 @@
import '../../model/account_data.dart';
import '../../model/endpoint_data.dart';
/// Shared helpers for Nextcloud OCS v2 endpoints.
///
/// Three call sites previously duplicated the same header dictionary and the
/// same URI scaffolding (TalkApi, AutocompleteApi, FileSharingApi). Anything
/// that talks to `https://<domain>/<base>/ocs/v2.php/...` should go through
/// these two helpers so additions like a new header or a different auth
/// scheme only need to change here.
/// Shared headers and URI builder for Nextcloud OCS v2 endpoints. Used by
/// TalkApi, AutocompleteApi, FileSharingApi.
class NextcloudOcs {
NextcloudOcs._();
/// The standard OCS request header set: JSON accept, OCS API marker,
/// HTTP Basic auth from the active [AccountData].
static Map<String, String> headers() => {
'Accept': 'application/json',
'OCS-APIRequest': 'true',
'Authorization': AccountData().getBasicAuthHeader(),
};
/// Builds an OCS URI by appending [pathSuffix] under `/ocs/v2.php/` of
/// the configured Nextcloud endpoint. Query parameters are converted to
/// strings (Uri rejects non-string values).
static Uri uri(String pathSuffix, {Map<String, dynamic>? queryParameters}) {
final endpoint = EndpointData().nextcloud();
return Uri.https(
@@ -120,38 +120,6 @@ enum GetRoomResponseObjectParticipantNotificationLevel {
@JsonValue(3) neverNotify,
}
// @JsonSerializable(explicitToJson: true)
// class GetRoomResponseObjectMessage {
// int id;
// String token;
// GetRoomResponseObjectMessageActorType actorType;
// String actorId;
// String actorDisplayName;
// int timestamp;
// String message;
// String systemMessage;
// GetRoomResponseObjectMessageType messageType;
// bool isReplyable;
// String referenceId;
//
//
// GetRoomResponseObjectMessage(
// this.id,
// this.token,
// this.actorType,
// this.actorId,
// this.actorDisplayName,
// this.timestamp,
// this.message,
// this.systemMessage,
// this.messageType,
// this.isReplyable,
// this.referenceId);
//
// factory GetRoomResponseObjectMessage.fromJson(Map<String, dynamic> json) => _$GetRoomResponseObjectMessageFromJson(json);
// Map<String, dynamic> toJson() => _$GetRoomResponseObjectMessageToJson(this);
// }
enum GetRoomResponseObjectMessageActorType {
@JsonValue('deleted_users') deletedUsers,
@JsonValue('users') user,
+30 -4
View File
@@ -4,6 +4,7 @@ import 'dart:convert';
import 'package:localstore/localstore.dart';
import 'api_response.dart';
import 'errors/parse_exception.dart';
abstract class RequestCache<T extends ApiResponse?> {
static const int cacheNothing = 0;
@@ -81,10 +82,8 @@ abstract class RequestCache<T extends ApiResponse?> {
}
/// Concrete [RequestCache] that delegates the two overrides to functions
/// passed in the constructor. Used to collapse the dozens of one-class-per-
/// endpoint cache files that all just forward to `<Endpoint>().run()` and
/// `<Response>.fromJson(jsonDecode(...))`.
/// Concrete [RequestCache] that takes the two overrides as constructor
/// callbacks instead of requiring a subclass per endpoint.
class SimpleCache<T extends ApiResponse?> extends RequestCache<T> {
final Future<T> Function() _loader;
final T Function(Map<String, dynamic> json) _fromJson;
@@ -115,3 +114,30 @@ class SimpleCache<T extends ApiResponse?> extends RequestCache<T> {
@override
T onLocalData(String json) => _fromJson(jsonDecode(json) as Map<String, dynamic>);
}
/// Captures the latest cache payload (cached or network) and rethrows the
/// captured network error if no payload arrived. Collapses the
/// `latest`/`capturedError`/`await ready` boilerplate that DataProviders
/// otherwise repeat per endpoint.
Future<T> resolveFromCache<T extends ApiResponse?>(
RequestCache<T> Function(void Function(T) onUpdate, void Function(Exception) onError) build, {
void Function(Object)? onError,
String? operationName,
}) async {
T? latest;
Object? capturedError;
final cache = build(
(data) => latest = data,
(e) {
capturedError = e;
onError?.call(e);
},
);
await cache.ready;
if (latest != null) return latest as T;
final err = capturedError;
if (err != null) throw err;
throw ParseException(
technicalDetails: operationName != null ? 'No data and no error from $operationName' : null,
);
}