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
@@ -41,6 +41,12 @@ class LoadableStateBloc extends Bloc<LoadableStateEvent, LoadableStateState> {
? Colors.grey.shade600
: Theme.of(context).primaryColor;
Color connectionForegroundColor(BuildContext context) => connectivityStatusKnown() && !isConnected()
? Colors.white
: ThemeData.estimateBrightnessForColor(Theme.of(context).primaryColor) == Brightness.dark
? Colors.white
: Colors.black;
String connectionText({int? lastUpdated}) => connectivityStatusKnown()
? isConnected()
? 'Verbindung fehlgeschlagen'
@@ -6,6 +6,7 @@ part 'loading_error.freezed.dart';
abstract class LoadingError with _$LoadingError {
const factory LoadingError({
required String message,
String? technicalDetails,
@Default(false) bool allowRetry,
}) = _LoadingError;
}
@@ -14,7 +14,7 @@ T _$identity<T>(T value) => value;
/// @nodoc
mixin _$LoadingError {
String get message; bool get allowRetry;
String get message; String? get technicalDetails; bool get allowRetry;
/// Create a copy of LoadingError
/// with the given fields replaced by the non-null parameter values.
@JsonKey(includeFromJson: false, includeToJson: false)
@@ -25,16 +25,16 @@ $LoadingErrorCopyWith<LoadingError> get copyWith => _$LoadingErrorCopyWithImpl<L
@override
bool operator ==(Object other) {
return identical(this, other) || (other.runtimeType == runtimeType&&other is LoadingError&&(identical(other.message, message) || other.message == message)&&(identical(other.allowRetry, allowRetry) || other.allowRetry == allowRetry));
return identical(this, other) || (other.runtimeType == runtimeType&&other is LoadingError&&(identical(other.message, message) || other.message == message)&&(identical(other.technicalDetails, technicalDetails) || other.technicalDetails == technicalDetails)&&(identical(other.allowRetry, allowRetry) || other.allowRetry == allowRetry));
}
@override
int get hashCode => Object.hash(runtimeType,message,allowRetry);
int get hashCode => Object.hash(runtimeType,message,technicalDetails,allowRetry);
@override
String toString() {
return 'LoadingError(message: $message, allowRetry: $allowRetry)';
return 'LoadingError(message: $message, technicalDetails: $technicalDetails, allowRetry: $allowRetry)';
}
@@ -45,7 +45,7 @@ abstract mixin class $LoadingErrorCopyWith<$Res> {
factory $LoadingErrorCopyWith(LoadingError value, $Res Function(LoadingError) _then) = _$LoadingErrorCopyWithImpl;
@useResult
$Res call({
String message, bool allowRetry
String message, String? technicalDetails, bool allowRetry
});
@@ -62,10 +62,11 @@ class _$LoadingErrorCopyWithImpl<$Res>
/// Create a copy of LoadingError
/// with the given fields replaced by the non-null parameter values.
@pragma('vm:prefer-inline') @override $Res call({Object? message = null,Object? allowRetry = null,}) {
@pragma('vm:prefer-inline') @override $Res call({Object? message = null,Object? technicalDetails = freezed,Object? allowRetry = null,}) {
return _then(_self.copyWith(
message: null == message ? _self.message : message // ignore: cast_nullable_to_non_nullable
as String,allowRetry: null == allowRetry ? _self.allowRetry : allowRetry // ignore: cast_nullable_to_non_nullable
as String,technicalDetails: freezed == technicalDetails ? _self.technicalDetails : technicalDetails // ignore: cast_nullable_to_non_nullable
as String?,allowRetry: null == allowRetry ? _self.allowRetry : allowRetry // ignore: cast_nullable_to_non_nullable
as bool,
));
}
@@ -151,10 +152,10 @@ return $default(_that);case _:
/// }
/// ```
@optionalTypeArgs TResult maybeWhen<TResult extends Object?>(TResult Function( String message, bool allowRetry)? $default,{required TResult orElse(),}) {final _that = this;
@optionalTypeArgs TResult maybeWhen<TResult extends Object?>(TResult Function( String message, String? technicalDetails, bool allowRetry)? $default,{required TResult orElse(),}) {final _that = this;
switch (_that) {
case _LoadingError() when $default != null:
return $default(_that.message,_that.allowRetry);case _:
return $default(_that.message,_that.technicalDetails,_that.allowRetry);case _:
return orElse();
}
@@ -172,10 +173,10 @@ return $default(_that.message,_that.allowRetry);case _:
/// }
/// ```
@optionalTypeArgs TResult when<TResult extends Object?>(TResult Function( String message, bool allowRetry) $default,) {final _that = this;
@optionalTypeArgs TResult when<TResult extends Object?>(TResult Function( String message, String? technicalDetails, bool allowRetry) $default,) {final _that = this;
switch (_that) {
case _LoadingError():
return $default(_that.message,_that.allowRetry);case _:
return $default(_that.message,_that.technicalDetails,_that.allowRetry);case _:
throw StateError('Unexpected subclass');
}
@@ -192,10 +193,10 @@ return $default(_that.message,_that.allowRetry);case _:
/// }
/// ```
@optionalTypeArgs TResult? whenOrNull<TResult extends Object?>(TResult? Function( String message, bool allowRetry)? $default,) {final _that = this;
@optionalTypeArgs TResult? whenOrNull<TResult extends Object?>(TResult? Function( String message, String? technicalDetails, bool allowRetry)? $default,) {final _that = this;
switch (_that) {
case _LoadingError() when $default != null:
return $default(_that.message,_that.allowRetry);case _:
return $default(_that.message,_that.technicalDetails,_that.allowRetry);case _:
return null;
}
@@ -207,10 +208,11 @@ return $default(_that.message,_that.allowRetry);case _:
class _LoadingError implements LoadingError {
const _LoadingError({required this.message, this.allowRetry = false});
const _LoadingError({required this.message, this.technicalDetails, this.allowRetry = false});
@override final String message;
@override final String? technicalDetails;
@override@JsonKey() final bool allowRetry;
/// Create a copy of LoadingError
@@ -223,16 +225,16 @@ _$LoadingErrorCopyWith<_LoadingError> get copyWith => __$LoadingErrorCopyWithImp
@override
bool operator ==(Object other) {
return identical(this, other) || (other.runtimeType == runtimeType&&other is _LoadingError&&(identical(other.message, message) || other.message == message)&&(identical(other.allowRetry, allowRetry) || other.allowRetry == allowRetry));
return identical(this, other) || (other.runtimeType == runtimeType&&other is _LoadingError&&(identical(other.message, message) || other.message == message)&&(identical(other.technicalDetails, technicalDetails) || other.technicalDetails == technicalDetails)&&(identical(other.allowRetry, allowRetry) || other.allowRetry == allowRetry));
}
@override
int get hashCode => Object.hash(runtimeType,message,allowRetry);
int get hashCode => Object.hash(runtimeType,message,technicalDetails,allowRetry);
@override
String toString() {
return 'LoadingError(message: $message, allowRetry: $allowRetry)';
return 'LoadingError(message: $message, technicalDetails: $technicalDetails, allowRetry: $allowRetry)';
}
@@ -243,7 +245,7 @@ abstract mixin class _$LoadingErrorCopyWith<$Res> implements $LoadingErrorCopyWi
factory _$LoadingErrorCopyWith(_LoadingError value, $Res Function(_LoadingError) _then) = __$LoadingErrorCopyWithImpl;
@override @useResult
$Res call({
String message, bool allowRetry
String message, String? technicalDetails, bool allowRetry
});
@@ -260,10 +262,11 @@ class __$LoadingErrorCopyWithImpl<$Res>
/// Create a copy of LoadingError
/// with the given fields replaced by the non-null parameter values.
@override @pragma('vm:prefer-inline') $Res call({Object? message = null,Object? allowRetry = null,}) {
@override @pragma('vm:prefer-inline') $Res call({Object? message = null,Object? technicalDetails = freezed,Object? allowRetry = null,}) {
return _then(_LoadingError(
message: null == message ? _self.message : message // ignore: cast_nullable_to_non_nullable
as String,allowRetry: null == allowRetry ? _self.allowRetry : allowRetry // ignore: cast_nullable_to_non_nullable
as String,technicalDetails: freezed == technicalDetails ? _self.technicalDetails : technicalDetails // ignore: cast_nullable_to_non_nullable
as String?,allowRetry: null == allowRetry ? _self.allowRetry : allowRetry // ignore: cast_nullable_to_non_nullable
as bool,
));
}
@@ -88,6 +88,7 @@ class LoadableStateConsumer<TController extends Bloc<LoadableHydratedBlocEvent<T
visible: showErrorBar,
hasContent: hasContent,
message: loadableState.error?.message,
technicalDetails: loadableState.error?.technicalDetails,
lastUpdated: loadableState.lastFetch,
),
Expanded(
@@ -95,7 +96,11 @@ class LoadableStateConsumer<TController extends Bloc<LoadableHydratedBlocEvent<T
children: [
LoadableStatePrimaryLoading(visible: showPrimaryLoading),
LoadableStateBackgroundLoading(visible: showBackgroundLoading),
LoadableStateErrorScreen(visible: showError, message: loadableState.error?.message),
LoadableStateErrorScreen(
visible: showError,
message: loadableState.error?.message,
technicalDetails: loadableState.error?.technicalDetails,
),
AnimatedOpacity(
opacity: hasContent ? 1.0 : 0.0,
@@ -10,11 +10,13 @@ class LoadableStateErrorBar extends StatelessWidget {
final bool visible;
final bool hasContent;
final String? message;
final String? technicalDetails;
final int? lastUpdated;
const LoadableStateErrorBar({
required this.visible,
this.hasContent = false,
this.message,
this.technicalDetails,
this.lastUpdated,
super.key,
});
@@ -48,7 +50,12 @@ class LoadableStateErrorBar extends StatelessWidget {
return InkWell(
onTap: () {
if(!bloc.isConnected()) return;
InfoDialog.show(context, 'Exception: ${message.toString()}');
final body = [
if (message != null && message!.isNotEmpty) message!,
if (technicalDetails != null && technicalDetails!.isNotEmpty) technicalDetails!,
].join('\n\n');
if (body.isEmpty) return;
InfoDialog.show(context, body);
},
child: Container(
height: 20,
@@ -85,13 +92,17 @@ class _LoadableStateErrorBarTextState extends State<LoadableStateErrorBarText> {
@override
Widget build(BuildContext context) {
var bloc = context.watch<LoadableStateBloc>();
final foreground = bloc.connectionForegroundColor(context);
return Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(bloc.connectionIcon(), size: 14),
Icon(bloc.connectionIcon(), size: 14, color: foreground),
const SizedBox(width: 10),
Text(bloc.connectionText(lastUpdated: widget.lastUpdated), style: const TextStyle(fontSize: 12))
Text(
bloc.connectionText(lastUpdated: widget.lastUpdated),
style: TextStyle(fontSize: 12, color: foreground),
),
],
);
}
@@ -1,49 +1,69 @@
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import '../../../../../widget/info_dialog.dart';
import '../bloc/loadable_state_bloc.dart';
import 'loadable_state_consumer.dart';
class LoadableStateErrorScreen extends StatelessWidget {
final bool visible;
final String? message;
const LoadableStateErrorScreen({required this.visible, this.message, super.key});
final String? technicalDetails;
const LoadableStateErrorScreen({
required this.visible,
this.message,
this.technicalDetails,
super.key,
});
@override
Widget build(BuildContext context) {
final bloc = context.watch<LoadableStateBloc>();
final isOffline = bloc.connectivityStatusKnown() && !bloc.isConnected();
final headline = isOffline ? bloc.connectionText() : (message ?? bloc.connectionText());
return AnimatedOpacity(
opacity: visible ? 1.0 : 0.0,
duration: LoadableStateConsumer.animationDuration,
curve: Curves.easeInOut,
child: !visible ? null : Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
crossAxisAlignment: CrossAxisAlignment.center,
children: [
Icon(bloc.connectionIcon(), size: 40),
const SizedBox(height: 10),
Text(bloc.connectionText(), style: const TextStyle(fontSize: 20)),
if(bloc.allowRetry()) ...[
const SizedBox(height: 10),
TextButton(onPressed: () => bloc.reFetch!(), child: const Text('Erneut versuschen')),
const SizedBox(height: 40),
Padding(
padding: const EdgeInsets.symmetric(horizontal: 20),
child: Text(
message ?? 'Task failed successfully :)',
style: TextStyle(
color: Theme.of(context).hintColor,
fontSize: 12
),
maxLines: 10,
overflow: TextOverflow.ellipsis,
),
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 24),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
crossAxisAlignment: CrossAxisAlignment.center,
children: [
Icon(bloc.connectionIcon(), size: 40),
const SizedBox(height: 12),
Text(
headline,
style: const TextStyle(fontSize: 20),
textAlign: TextAlign.center,
),
if (!isOffline && message != null && message != headline) ...[
const SizedBox(height: 8),
Text(
message!,
style: TextStyle(color: Theme.of(context).hintColor, fontSize: 14),
textAlign: TextAlign.center,
),
],
if (bloc.allowRetry()) ...[
const SizedBox(height: 16),
TextButton(
onPressed: () => bloc.reFetch!(),
child: const Text('Erneut versuchen'),
),
],
if (technicalDetails != null) ...[
const SizedBox(height: 4),
TextButton(
onPressed: () => InfoDialog.show(context, technicalDetails!),
child: const Text('Details anzeigen'),
),
],
],
],
),
),
),
);
@@ -1,5 +1,6 @@
import 'package:flutter/material.dart';
import '../../../../../widget/app_progress_indicator.dart';
import 'loadable_state_consumer.dart';
class LoadableStatePrimaryLoading extends StatelessWidget {
@@ -11,6 +12,6 @@ class LoadableStatePrimaryLoading extends StatelessWidget {
opacity: visible ? 1.0 : 0.0,
duration: LoadableStateConsumer.animationDuration,
curve: Curves.easeInOut,
child: const Center(child: CircularProgressIndicator()),
child: const Center(child: AppProgressIndicator.large()),
);
}
@@ -2,6 +2,7 @@ import 'dart:developer';
import 'package:hydrated_bloc/hydrated_bloc.dart';
import '../../../../../api/errors/error_mapper.dart';
import '../../loadableState/loading_error.dart';
import '../../repository/repository.dart';
import 'loadable_hydrated_bloc_event.dart';
@@ -78,8 +79,9 @@ abstract class LoadableHydratedBloc<
(e) {
log('Error while fetching ${TState.toString()}: ${e.toString()}');
add(Error(LoadingError(
message: e.message ?? e.toString(),
allowRetry: true,
message: errorToUserMessage(e),
technicalDetails: errorToTechnicalDetails(e),
allowRetry: errorAllowsRetry(e),
)));
},
).then((value) {