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}');
}
}
}
+28 -5
View File
@@ -32,7 +32,7 @@ import 'storage/settings.dart';
import 'theming/dark_app_theme.dart';
import 'theming/light_app_theme.dart';
import 'view/login/login.dart';
import 'widget/placeholder_view.dart';
import 'widget/app_progress_indicator.dart';
Future<void> main() async {
log('MarianumMobile started');
@@ -68,9 +68,21 @@ Future<void> main() async {
);
if (kReleaseMode) {
ErrorWidget.builder = (error) => PlaceholderView(
icon: Icons.phonelink_erase_rounded,
text: error.toStringShort(),
ErrorWidget.builder = (error) => Material(
color: Colors.white,
child: Center(
child: Padding(
padding: const EdgeInsets.all(24),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
const Icon(Icons.phonelink_erase_rounded, size: 40),
const SizedBox(height: 12),
Text(error.toStringShort(), textAlign: TextAlign.center),
],
),
),
),
);
}
@@ -156,7 +168,18 @@ class _MainState extends State<Main> {
case AccountStatus.loggedOut:
return const Login();
case AccountStatus.undefined:
return const PlaceholderView(icon: Icons.timer, text: 'Daten werden geladen');
return const Scaffold(
body: Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
AppProgressIndicator.large(),
SizedBox(height: 16),
Text('Konto wird geladen…'),
],
),
),
);
}
},
),
@@ -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) {
@@ -1,3 +1,4 @@
import '../../../../../api/errors/error_mapper.dart';
import '../../../../../api/marianumcloud/talk/chat/getChatResponse.dart';
import '../../../infrastructure/loadableState/loading_error.dart';
import '../../../infrastructure/utilityWidgets/loadableHydratedBloc/loadable_hydrated_bloc.dart';
@@ -75,8 +76,9 @@ class ChatBloc extends LoadableHydratedBloc<ChatEvent, ChatState, ChatRepository
}
if (capturedError != null) {
add(Error(LoadingError(
message: capturedError.toString(),
allowRetry: true,
message: errorToUserMessage(capturedError),
technicalDetails: errorToTechnicalDetails(capturedError),
allowRetry: errorAllowsRetry(capturedError),
)));
}
}
@@ -1,5 +1,6 @@
import 'package:flutter_app_badge/flutter_app_badge.dart';
import '../../../../../api/errors/error_mapper.dart';
import '../../../infrastructure/loadableState/loading_error.dart';
import '../../../infrastructure/utilityWidgets/loadableHydratedBloc/loadable_hydrated_bloc.dart';
import '../../../infrastructure/utilityWidgets/loadableHydratedBloc/loadable_hydrated_bloc_event.dart';
@@ -59,8 +60,9 @@ class ChatListBloc extends LoadableHydratedBloc<ChatListEvent, ChatListState, Ch
}
if (capturedError != null) {
add(Error(LoadingError(
message: capturedError.toString(),
allowRetry: true,
message: errorToUserMessage(capturedError),
technicalDetails: errorToTechnicalDetails(capturedError),
allowRetry: errorAllowsRetry(capturedError),
)));
}
}
@@ -1,3 +1,4 @@
import '../../../../../api/errors/error_mapper.dart';
import '../../../../../api/marianumcloud/webdav/queries/listFiles/listFilesResponse.dart';
import '../../../infrastructure/loadableState/loading_error.dart';
import '../../../infrastructure/utilityWidgets/loadableHydratedBloc/loadable_hydrated_bloc.dart';
@@ -74,8 +75,9 @@ class FilesBloc extends LoadableHydratedBloc<FilesEvent, FilesState, FilesReposi
}
if (capturedError != null) {
add(Error(LoadingError(
message: capturedError.toString(),
allowRetry: true,
message: errorToUserMessage(capturedError),
technicalDetails: errorToTechnicalDetails(capturedError),
allowRetry: errorAllowsRetry(capturedError),
)));
}
}
@@ -1,3 +1,4 @@
import 'dart:async';
import 'dart:developer';
import 'package:easy_debounce/easy_debounce.dart';
@@ -9,14 +10,23 @@ import '../../app_modules.dart';
class SettingsCubit extends HydratedCubit<Settings> {
static const _debounceTag = 'settings_persist';
bool _emitScheduled = false;
SettingsCubit() : super(DefaultSettings.get());
Settings val({bool write = false}) {
if (write) {
// Notify listeners immediately so the UI reflects the mutation right away;
// debounce the actual persistence to disk to avoid hammering on rapid edits.
_emitFreshInstance();
// Defer the emit until the synchronous mutation on the returned object
// has finished. Without this scheduleMicrotask the cubit emits a copy
// captured *before* the assignment runs, so listeners (and HydratedBloc
// persistence) see the old value on the first emit.
if (!_emitScheduled) {
_emitScheduled = true;
scheduleMicrotask(() {
_emitScheduled = false;
_emitFreshInstance();
});
}
EasyDebounce.debounce(_debounceTag, const Duration(milliseconds: 500), _emitFreshInstance);
}
return state;
+11
View File
@@ -2,6 +2,17 @@ import 'package:flutter/material.dart';
import '../widget/dropdown_display.dart';
class AppSpacing {
static const double xs = 4;
static const double sm = 8;
static const double md = 16;
static const double lg = 24;
static const double xl = 40;
}
TextStyle inputErrorStyle(BuildContext context) =>
TextStyle(color: Theme.of(context).colorScheme.error);
class AppTheme {
static DropdownDisplay getDisplayOptions(ThemeMode theme) {
switch(theme) {
+13 -13
View File
@@ -2,7 +2,6 @@ import 'dart:io';
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:loader_overlay/loader_overlay.dart';
import 'package:persistent_bottom_nav_bar_v2/persistent_bottom_nav_bar_v2.dart';
import '../../../api/marianumcloud/webdav/queries/listFiles/cacheableFile.dart';
@@ -12,6 +11,7 @@ import '../../../state/app/infrastructure/utilityWidgets/bloc_module.dart';
import '../../../state/app/modules/files/bloc/files_bloc.dart';
import '../../../state/app/modules/files/bloc/files_state.dart';
import '../../../state/app/modules/settings/bloc/settings_cubit.dart';
import '../../../widget/async_action_button.dart';
import '../../../widget/file_pick.dart';
import '../../../widget/placeholder_view.dart';
import 'widgets/file_element.dart';
@@ -175,12 +175,10 @@ class _FilesViewState extends State<_FilesView> {
foldersToTop: context.watch<SettingsCubit>().val().fileSettings.sortFoldersToTop,
reversed: currentSortDirection,
);
return LoaderOverlay(
child: ListView.builder(
padding: EdgeInsets.zero,
itemCount: files.length,
itemBuilder: (context, index) => FileElement(files[index], widget.path, bloc.refresh),
),
return ListView.builder(
padding: EdgeInsets.zero,
itemCount: files.length,
itemBuilder: (context, index) => FileElement(files[index], widget.path, bloc.refresh),
);
},
),
@@ -233,15 +231,17 @@ class _FilesViewState extends State<_FilesView> {
content: TextField(
controller: inputController,
decoration: const InputDecoration(labelText: 'Name'),
autofocus: true,
),
actions: [
TextButton(onPressed: () => Navigator.of(dialogCtx).pop(), child: const Text('Abbrechen')),
TextButton(
onPressed: () {
bloc.createFolder(inputController.text);
Navigator.of(dialogCtx).pop();
AsyncDialogAction(
confirmLabel: 'Ordner erstellen',
onConfirm: () async {
if (inputController.text.trim().isEmpty) {
throw Exception('Bitte einen Namen eingeben.');
}
await bloc.createFolder(inputController.text.trim());
},
child: const Text('Ordner erstellen'),
),
],
),
@@ -254,7 +254,7 @@ class _FilesUploadDialogState extends State<FilesUploadDialog> {
border: const UnderlineInputBorder(),
label: Text('Datei ${index+1}'),
errorText: currentFile.isConflicting ? 'existiert bereits' : null,
errorStyle: const TextStyle(color: Colors.red),
errorStyle: TextStyle(color: Theme.of(context).colorScheme.error),
),
onChanged: (input) {
currentFile.fileName = input;
@@ -159,11 +159,12 @@ class _FileElementState extends State<FileElement> {
showDialog(context: context, builder: (context) => ConfirmDialog(
title: 'Element löschen?',
content: 'Das Element wird unwiederruflich gelöscht.',
onConfirm: () {
WebdavApi.webdav
.then((value) => value.delete(PathUri.parse(widget.file.path)))
.then((value) => widget.refetch());
}
confirmButton: 'Löschen',
onConfirmAsync: () async {
final webdav = await WebdavApi.webdav;
await webdav.delete(PathUri.parse(widget.file.path));
widget.refetch();
},
));
},
),
@@ -16,7 +16,7 @@ class AboutSection extends StatelessWidget {
@override
Widget build(BuildContext context) {
final settings = context.read<SettingsCubit>();
final settings = context.watch<SettingsCubit>();
return Column(
children: [
ListTile(
@@ -22,11 +22,11 @@ class AccountSection extends StatelessWidget {
void _showLogoutDialog(BuildContext context) {
showDialog(
context: context,
builder: (context) => ConfirmDialog(
builder: (dialogContext) => ConfirmDialog(
title: 'Abmelden?',
content: 'Möchtest du dich wirklich abmelden?',
confirmButton: 'Abmelden',
onConfirm: () async {
onConfirmAsync: () async {
final prefs = await SharedPreferences.getInstance();
await prefs.clear();
PaintingBinding.instance.imageCache.clear();
@@ -9,7 +9,7 @@ class AppearanceSection extends StatelessWidget {
@override
Widget build(BuildContext context) {
final settings = context.read<SettingsCubit>();
final settings = context.watch<SettingsCubit>();
return ListTile(
leading: const Icon(Icons.dark_mode_outlined),
title: const Text('Farbgebung'),
@@ -6,6 +6,7 @@ import 'package:flutter_bloc/flutter_bloc.dart';
import '../../../../routing/app_routes.dart';
import '../../../../state/app/modules/settings/bloc/settings_cubit.dart';
import '../../../../storage/settings.dart' as model;
import '../../../../widget/centered_leading.dart';
import '../../../../widget/confirm_dialog.dart';
import '../../../../widget/debug/cache_view.dart';
@@ -28,34 +29,43 @@ class _DevToolsSectionState extends State<DevToolsSection> {
title: const Text('Performance overlays'),
trailing: const Icon(Icons.arrow_right),
onTap: () {
showDialog(context: context, builder: (context) => SimpleDialog(
children: [
ListTile(
leading: const Icon(Icons.auto_graph_outlined),
title: const Text('Performance graph'),
trailing: Checkbox(
value: widget.settings.val().devToolsSettings.showPerformanceOverlay,
onChanged: (e) => widget.settings.val(write: true).devToolsSettings.showPerformanceOverlay = e!,
),
),
ListTile(
leading: const Icon(Icons.screen_search_desktop_outlined),
title: const Text('Indicate offscreen layers'),
trailing: Checkbox(
value: widget.settings.val().devToolsSettings.checkerboardOffscreenLayers,
onChanged: (e) => widget.settings.val(write: true).devToolsSettings.checkerboardOffscreenLayers = e!,
),
),
ListTile(
leading: const Icon(Icons.imagesearch_roller_outlined),
title: const Text('Indicate raster cache images'),
trailing: Checkbox(
value: widget.settings.val().devToolsSettings.checkerboardRasterCacheImages,
onChanged: (e) => widget.settings.val(write: true).devToolsSettings.checkerboardRasterCacheImages = e!,
),
),
],
));
showDialog(
context: context,
builder: (dialogCtx) => BlocBuilder<SettingsCubit, model.Settings>(
bloc: widget.settings,
builder: (_, _) {
final dev = widget.settings.val().devToolsSettings;
return SimpleDialog(
children: [
ListTile(
leading: const Icon(Icons.auto_graph_outlined),
title: const Text('Performance graph'),
trailing: Checkbox(
value: dev.showPerformanceOverlay,
onChanged: (e) => widget.settings.val(write: true).devToolsSettings.showPerformanceOverlay = e!,
),
),
ListTile(
leading: const Icon(Icons.screen_search_desktop_outlined),
title: const Text('Indicate offscreen layers'),
trailing: Checkbox(
value: dev.checkerboardOffscreenLayers,
onChanged: (e) => widget.settings.val(write: true).devToolsSettings.checkerboardOffscreenLayers = e!,
),
),
ListTile(
leading: const Icon(Icons.imagesearch_roller_outlined),
title: const Text('Indicate raster cache images'),
trailing: Checkbox(
value: dev.checkerboardRasterCacheImages,
onChanged: (e) => widget.settings.val(write: true).devToolsSettings.checkerboardRasterCacheImages = e!,
),
),
],
);
},
),
);
},
),
ListTile(
@@ -8,7 +8,7 @@ class FilesSection extends StatelessWidget {
@override
Widget build(BuildContext context) {
final settings = context.read<SettingsCubit>();
final settings = context.watch<SettingsCubit>();
return Column(
children: [
ListTile(
@@ -10,7 +10,7 @@ class TalkSection extends StatelessWidget {
@override
Widget build(BuildContext context) {
final settings = context.read<SettingsCubit>();
final settings = context.watch<SettingsCubit>();
final talkSettings = settings.val().talkSettings;
final notificationSettings = settings.val().notificationSettings;
return Column(
@@ -2,7 +2,6 @@ import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import '../../../../state/app/modules/settings/bloc/settings_cubit.dart';
import '../../../../state/app/modules/timetable/bloc/timetable_bloc.dart';
import '../../../../view/pages/timetable/data/timetable_name_mode.dart';
class TimetableSection extends StatelessWidget {
@@ -10,8 +9,7 @@ class TimetableSection extends StatelessWidget {
@override
Widget build(BuildContext context) {
final settings = context.read<SettingsCubit>();
final timetableBloc = context.read<TimetableBloc>();
final settings = context.watch<SettingsCubit>();
final timetableSettings = settings.val().timetableSettings;
return Column(
children: [
@@ -34,10 +32,8 @@ class TimetableSection extends StatelessWidget {
),
))
.toList(),
onChanged: (value) {
settings.val(write: true).timetableSettings.timetableNameMode = value!;
timetableBloc.refresh();
},
onChanged: (value) =>
settings.val(write: true).timetableSettings.timetableNameMode = value!,
),
),
ListTile(
@@ -45,10 +41,8 @@ class TimetableSection extends StatelessWidget {
title: const Text('Doppelstunden zusammenhängend anzeigen'),
trailing: Checkbox(
value: timetableSettings.connectDoubleLessons,
onChanged: (e) {
settings.val(write: true).timetableSettings.connectDoubleLessons = e!;
timetableBloc.refresh();
},
onChanged: (e) =>
settings.val(write: true).timetableSettings.connectDoubleLessons = e!,
),
),
],
+16 -21
View File
@@ -1,8 +1,5 @@
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import '../../../state/app/modules/settings/bloc/settings_cubit.dart';
import '../../../storage/settings.dart' as model;
import 'sections/about_section.dart';
import 'sections/account_section.dart';
import 'sections/appearance_section.dart';
@@ -14,24 +11,22 @@ class Settings extends StatelessWidget {
const Settings({super.key});
@override
Widget build(BuildContext context) => BlocBuilder<SettingsCubit, model.Settings>(
builder: (context, _) => Scaffold(
appBar: AppBar(title: const Text('Einstellungen')),
body: ListView(
children: const [
AccountSection(),
Divider(),
AppearanceSection(),
Divider(),
TimetableSection(),
Divider(),
TalkSection(),
Divider(),
FilesSection(),
Divider(),
AboutSection(),
],
),
Widget build(BuildContext context) => Scaffold(
appBar: AppBar(title: const Text('Einstellungen')),
body: ListView(
children: const [
AccountSection(),
Divider(),
AppearanceSection(),
Divider(),
TimetableSection(),
Divider(),
TalkSection(),
Divider(),
FilesSection(),
Divider(),
AboutSection(),
],
),
);
}
+9 -3
View File
@@ -12,6 +12,7 @@ import '../../../state/app/modules/chatList/bloc/chat_list_state.dart';
import '../../../notification/notify_updater.dart';
import '../../../state/app/modules/settings/bloc/settings_cubit.dart';
import '../../../widget/confirm_dialog.dart';
import '../../../widget/placeholder_view.dart';
import 'widgets/chat_tile.dart';
import 'widgets/split_view_placeholder.dart';
import 'join_chat.dart';
@@ -144,9 +145,7 @@ class _ChatListViewState extends State<_ChatListView> {
title: 'Chat starten',
content: "Möchtest du einen Chat mit Nutzer '$username' starten?",
confirmButton: 'Chat starten',
onConfirm: () {
bloc.createDirectChat(username);
},
onConfirmAsync: () => bloc.createDirectChat(username),
).asDialog(context);
});
},
@@ -164,6 +163,13 @@ class _ChatListViewState extends State<_ChatListView> {
unreadToTop: talkSettings.sortUnreadToTop,
);
if (sorted.isEmpty) {
return const PlaceholderView(
icon: Icons.chat_bubble_outline,
text: 'Noch keine Chats — starte einen über das +-Symbol.',
);
}
return ListView(
padding: EdgeInsets.zero,
children: sorted.map((room) {
+10 -13
View File
@@ -2,9 +2,11 @@
import 'package:async/async.dart';
import 'package:flutter/material.dart';
import '../../../api/errors/error_mapper.dart';
import '../../../api/marianumcloud/autocomplete/autocompleteApi.dart';
import '../../../api/marianumcloud/autocomplete/autocompleteResponse.dart';
import '../../../model/endpoint_data.dart';
import '../../../widget/app_progress_indicator.dart';
import '../../../widget/placeholder_view.dart';
class JoinChat extends SearchDelegate<String> {
@@ -16,17 +18,9 @@ class JoinChat extends SearchDelegate<String> {
future: future!.value,
builder: (context, snapshot) {
if(snapshot.connectionState != ConnectionState.done) {
return Container(
padding: const EdgeInsets.all(10),
child: const Center(
child: SizedBox(
height: 20,
width: 20,
child: CircularProgressIndicator(
strokeWidth: 3,
),
),
),
return const Padding(
padding: EdgeInsets.all(10),
child: Center(child: AppProgressIndicator.medium()),
);
}
return const SizedBox.shrink();
@@ -76,10 +70,13 @@ class JoinChat extends SearchDelegate<String> {
}
);
} else if(snapshot.hasError) {
return const PlaceholderView(icon: Icons.search_off, text: 'Ein fehler ist aufgetreten. Bist du mit dem Internet verbunden?');
return PlaceholderView(
icon: Icons.search_off,
text: errorToUserMessage(snapshot.error),
);
}
return const Center(child: CircularProgressIndicator());
return const Center(child: AppProgressIndicator.large());
},
);
}
+17 -16
View File
@@ -14,6 +14,7 @@ import '../../../../api/marianumcloud/talk/reactMessage/reactMessageParams.dart'
import '../../../../api/marianumcloud/talk/room/getRoomResponse.dart';
import '../../../../extensions/text.dart';
import '../../../../state/app/modules/chat/bloc/chat_bloc.dart';
import '../../../../widget/async_action_button.dart';
import '../../../../widget/loading_spinner.dart';
import '../../files/widgets/file_element.dart';
import '../data/chat_bubble_styles.dart';
@@ -306,22 +307,22 @@ class _ChatBubbleState extends State<ChatBubble> with SingleTickerProviderStateM
padding: EdgeInsets.zero,
backgroundColor: hasSelfReacted ? Theme.of(context).primaryColor : null,
onPressed: () {
if(hasSelfReacted) {
// Delete existing reaction
DeleteReactMessage(
chatToken: widget.chatData.token,
messageId: widget.bubbleData.id,
params: DeleteReactMessageParams(e.key),
).run().then((value) => widget.refetch(renew: true));
} else {
// Add reaction
ReactMessage(
chatToken: widget.chatData.token,
messageId: widget.bubbleData.id,
params: ReactMessageParams(e.key)
).run().then((value) => widget.refetch(renew: true));
}
runWithErrorDialog(context, () async {
if (hasSelfReacted) {
await DeleteReactMessage(
chatToken: widget.chatData.token,
messageId: widget.bubbleData.id,
params: DeleteReactMessageParams(e.key),
).run();
} else {
await ReactMessage(
chatToken: widget.chatData.token,
messageId: widget.bubbleData.id,
params: ReactMessageParams(e.key),
).run();
}
widget.refetch(renew: true);
});
},
),
);
@@ -11,6 +11,8 @@ import '../../../../api/marianumcloud/talk/reactMessage/reactMessageParams.dart'
import '../../../../api/marianumcloud/talk/room/getRoomResponse.dart';
import '../../../../routing/app_routes.dart';
import '../../../../state/app/modules/chat/bloc/chat_bloc.dart';
import '../../../../widget/app_progress_indicator.dart';
import '../../../../widget/async_action_button.dart';
import '../../../../widget/debug/debug_tile.dart';
const _commonReactions = <String>['👍', '👎', '😆', '❤️', '👀'];
@@ -78,14 +80,12 @@ Future<void> showChatMessageOptionsDialog(
onTap: () => Navigator.of(dialogCtx).pop(),
),
if (canDelete)
ListTile(
AsyncListTile(
leading: const Icon(Icons.delete_outline),
title: const Text('Nachricht löschen'),
onTap: () async {
onPressed: () async {
await DeleteMessage(chatData.token, bubbleData.id).run();
if (!dialogCtx.mounted) return;
dialogCtx.read<ChatBloc>().refresh();
Navigator.of(dialogCtx).pop();
if (dialogCtx.mounted) dialogCtx.read<ChatBloc>().refresh();
},
),
DebugTile(dialogCtx).jsonData(bubbleData.toJson()),
@@ -94,7 +94,7 @@ Future<void> showChatMessageOptionsDialog(
);
}
class _ReactionsRow extends StatelessWidget {
class _ReactionsRow extends StatefulWidget {
final String chatToken;
final int messageId;
final void Function({bool renew}) onRefetch;
@@ -107,46 +107,83 @@ class _ReactionsRow extends StatelessWidget {
required this.dialogContext,
});
void _react(String emoji) {
Navigator.of(dialogContext).pop();
ReactMessage(
chatToken: chatToken,
messageId: messageId,
params: ReactMessageParams(emoji),
).run().then((_) => onRefetch(renew: true));
@override
State<_ReactionsRow> createState() => _ReactionsRowState();
}
class _ReactionsRowState extends State<_ReactionsRow> {
final AsyncActionController _controller = AsyncActionController();
@override
void dispose() {
_controller.dispose();
super.dispose();
}
Future<void> _react(String emoji) async {
final ok = await _controller.run(() async {
await ReactMessage(
chatToken: widget.chatToken,
messageId: widget.messageId,
params: ReactMessageParams(emoji),
).run();
});
if (!mounted) return;
if (ok) {
widget.onRefetch(renew: true);
if (widget.dialogContext.mounted) Navigator.of(widget.dialogContext).pop();
}
}
@override
Widget build(BuildContext context) => Column(
mainAxisSize: MainAxisSize.min,
children: [
Wrap(
alignment: WrapAlignment.center,
Widget build(BuildContext context) => AnimatedBuilder(
animation: _controller,
builder: (context, _) {
final busy = _controller.busy;
final err = _controller.error;
return Column(
mainAxisSize: MainAxisSize.min,
children: [
..._commonReactions.map(
(emoji) => TextButton(
style: TextButton.styleFrom(
padding: EdgeInsets.zero,
tapTargetSize: MaterialTapTargetSize.shrinkWrap,
minimumSize: const Size(40, 40),
Wrap(
alignment: WrapAlignment.center,
children: [
..._commonReactions.map(
(emoji) => TextButton(
style: TextButton.styleFrom(
padding: EdgeInsets.zero,
tapTargetSize: MaterialTapTargetSize.shrinkWrap,
minimumSize: const Size(40, 40),
),
onPressed: busy ? null : () => _react(emoji),
child: Text(emoji),
),
),
onPressed: () => _react(emoji),
child: Text(emoji),
),
IconButton(
onPressed: busy ? null : () => _showEmojiPicker(context),
style: IconButton.styleFrom(
padding: EdgeInsets.zero,
tapTargetSize: MaterialTapTargetSize.shrinkWrap,
minimumSize: const Size(40, 40),
),
icon: busy
? const AppProgressIndicator.small()
: const Icon(Icons.add_circle_outline_outlined),
),
],
),
IconButton(
onPressed: () => _showEmojiPicker(context),
style: IconButton.styleFrom(
padding: EdgeInsets.zero,
tapTargetSize: MaterialTapTargetSize.shrinkWrap,
minimumSize: const Size(40, 40),
if (err != null)
Padding(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 4),
child: Text(
err,
textAlign: TextAlign.center,
style: TextStyle(color: Theme.of(context).colorScheme.error, fontSize: 12),
),
),
icon: const Icon(Icons.add_circle_outline_outlined),
),
const Divider(),
],
),
const Divider(),
],
);
},
);
void _showEmojiPicker(BuildContext rowContext) {
+47 -31
View File
@@ -12,6 +12,7 @@ import '../../../../api/marianumcloud/talk/sendMessage/sendMessageParams.dart';
import '../../../../api/marianumcloud/webdav/webdavApi.dart';
import '../../../../state/app/modules/chat/bloc/chat_bloc.dart';
import '../../../../state/app/modules/settings/bloc/settings_cubit.dart';
import '../../../../widget/async_action_button.dart';
import '../../../../widget/file_pick.dart';
import '../../../../widget/focus_behaviour.dart';
import '../../files/files_upload_dialog.dart';
@@ -30,7 +31,8 @@ class ChatTextfield extends StatefulWidget {
class _ChatTextfieldState extends State<ChatTextfield> {
late SettingsCubit settings;
final TextEditingController _textBoxController = TextEditingController();
bool isLoading = false;
final AsyncActionController _sendController = AsyncActionController();
String? _sendError;
void share(String shareFolder, List<String> filePaths) {
for (final element in filePaths) {
@@ -92,6 +94,29 @@ class _ChatTextfieldState extends State<ChatTextfield> {
}
}
@override
void dispose() {
_sendController.dispose();
super.dispose();
}
Future<void> _sendMessage(ChatBloc chatBloc) async {
if (_textBoxController.text.isEmpty) return;
final text = _textBoxController.text;
final replyTo = chatBloc.state.data?.referenceMessageId?.toString();
setState(() => _sendError = null);
await SendMessage(
widget.sendToToken,
SendMessageParams(text, replyTo: replyTo),
).run();
if (!mounted) return;
chatBloc.refresh();
_textBoxController.text = '';
_setDraft('');
chatBloc.setReferenceMessageId(null);
_setDraftReply(null);
}
@override
Widget build(BuildContext context) {
_textBoxController.text = settings.val().talkSettings.drafts[widget.sendToToken] ?? '';
@@ -135,6 +160,14 @@ class _ChatTextfieldState extends State<ChatTextfield> {
child: Column(
children: [
replyBanner,
if (_sendError != null)
Padding(
padding: const EdgeInsets.only(bottom: 4),
child: Text(
_sendError!,
style: TextStyle(color: Theme.of(context).colorScheme.error, fontSize: 12),
),
),
Row(children: <Widget>[
GestureDetector(
onTap: () {
@@ -200,36 +233,19 @@ class _ChatTextfieldState extends State<ChatTextfield> {
),
),
const SizedBox(width: 15),
FloatingActionButton(
mini: true,
onPressed: () {
if (_textBoxController.text.isEmpty || isLoading) return;
setState(() => isLoading = true);
SendMessage(
widget.sendToToken,
SendMessageParams(
_textBoxController.text,
replyTo: chatBloc.state.data?.referenceMessageId?.toString(),
),
).run().then((_) {
if (!mounted) return;
chatBloc.refresh();
setState(() => isLoading = false);
_textBoxController.text = '';
_setDraft('');
chatBloc.setReferenceMessageId(null);
_setDraftReply(null);
});
},
backgroundColor: Theme.of(context).primaryColor,
elevation: 5,
child: isLoading
? Container(
padding: const EdgeInsets.all(10),
child: const CircularProgressIndicator(color: Colors.white, strokeWidth: 2),
)
: const Icon(Icons.send, color: Colors.white, size: 18),
ValueListenableBuilder<TextEditingValue>(
valueListenable: _textBoxController,
builder: (context, value, _) => AsyncFab(
mini: true,
heroTag: 'chatSend_${widget.sendToToken}',
icon: Icons.send,
backgroundColor: Theme.of(context).primaryColor,
foregroundColor: Colors.white,
controller: _sendController,
onPressed: value.text.trim().isEmpty ? null : () => _sendMessage(chatBloc),
onError: (message) => setState(() => _sendError = message),
onSuccess: () => setState(() => _sendError = null),
),
),
]),
],
+39 -50
View File
@@ -1,3 +1,4 @@
import 'dart:async';
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
@@ -11,6 +12,7 @@ import '../../../../api/marianumcloud/talk/setReadMarker/setReadMarkerParams.dar
import '../../../../model/account_data.dart';
import '../../../../state/app/modules/chat/bloc/chat_bloc.dart';
import '../../../../state/app/modules/chatList/bloc/chat_list_bloc.dart';
import '../../../../widget/async_action_button.dart';
import '../../../../widget/confirm_dialog.dart';
import '../../../../widget/debug/debug_tile.dart';
import '../../../../widget/user_avatar.dart';
@@ -42,15 +44,14 @@ class _ChatTileState extends State<ChatTile> {
void _refreshList() => context.read<ChatListBloc>().refresh();
void setCurrentAsRead() {
SetReadMarker(
Future<void> _setCurrentAsRead() async {
await SetReadMarker(
widget.data.token,
true,
setReadMarkerParams: SetReadMarkerParams(lastReadMessage: widget.data.lastMessage.id),
).run().then((_) {
if (!mounted) return;
_refreshList();
});
).run();
if (!mounted) return;
_refreshList();
}
@override
@@ -116,7 +117,7 @@ class _ChatTileState extends State<ChatTile> {
),
onTap: () {
if (selfUsername == null) return;
setCurrentAsRead();
unawaited(_setCurrentAsRead());
final view = ChatView(room: widget.data, selfId: selfUsername!, avatar: circleAvatar);
TalkNavigator.pushSplitView(context, view, overrideToSingleSubScreen: true);
context.read<ChatBloc>().setToken(widget.data.token);
@@ -125,65 +126,53 @@ class _ChatTileState extends State<ChatTile> {
if (widget.disableContextActions) return;
showDialog(context: context, builder: (dialogCtx) => SimpleDialog(
children: [
Visibility(
visible: widget.data.unreadMessages > 0,
replacement: ListTile(
leading: const Icon(Icons.mark_chat_unread_outlined),
title: const Text('Als ungelesen markieren'),
onTap: () {
SetReadMarker(widget.data.token, false).run().then((_) {
if (mounted) _refreshList();
});
Navigator.of(dialogCtx).pop();
},
),
child: ListTile(
if (widget.data.unreadMessages > 0)
AsyncListTile(
leading: const Icon(Icons.mark_chat_read_outlined),
title: const Text('Als gelesen markieren'),
onTap: () {
setCurrentAsRead();
Navigator.of(dialogCtx).pop();
onPressed: _setCurrentAsRead,
)
else
AsyncListTile(
leading: const Icon(Icons.mark_chat_unread_outlined),
title: const Text('Als ungelesen markieren'),
onPressed: () async {
await SetReadMarker(widget.data.token, false).run();
if (mounted) _refreshList();
},
),
),
Visibility(
visible: widget.data.isFavorite,
replacement: ListTile(
leading: const Icon(Icons.star_outline),
title: const Text('Zu Favoriten hinzufügen'),
onTap: () {
SetFavorite(widget.data.token, true).run().then((_) {
if (mounted) _refreshList();
});
Navigator.of(dialogCtx).pop();
},
),
child: ListTile(
if (widget.data.isFavorite)
AsyncListTile(
leading: const Icon(Icons.stars_outlined),
title: const Text('Von Favoriten entfernen'),
onTap: () {
SetFavorite(widget.data.token, false).run().then((_) {
if (mounted) _refreshList();
});
Navigator.of(dialogCtx).pop();
onPressed: () async {
await SetFavorite(widget.data.token, false).run();
if (mounted) _refreshList();
},
)
else
AsyncListTile(
leading: const Icon(Icons.star_outline),
title: const Text('Zu Favoriten hinzufügen'),
onPressed: () async {
await SetFavorite(widget.data.token, true).run();
if (mounted) _refreshList();
},
),
),
ListTile(
leading: const Icon(Icons.delete_outline),
title: const Text('Konversation verlassen'),
onTap: () {
Navigator.of(dialogCtx).pop();
ConfirmDialog(
title: 'Chat verlassen',
content: 'Du benötigst ggf. eine Einladung um erneut beizutreten.',
confirmButton: 'Löschen',
onConfirm: () {
LeaveRoom(widget.data.token).run().then((_) {
if (mounted) _refreshList();
});
Navigator.of(dialogCtx).pop();
confirmButton: 'Verlassen',
onConfirmAsync: () async {
await LeaveRoom(widget.data.token).run();
if (mounted) _refreshList();
},
).asDialog(dialogCtx);
).asDialog(context);
},
),
DebugTile(dialogCtx).jsonData(widget.data.toJson()),
@@ -15,9 +15,7 @@ Completer<void> showDeleteCustomEventDialog(BuildContext context, CustomTimetabl
content: 'Der ${event.rrule.isEmpty ? "Termin" : "Serientermin"} wird unwiederruflich gelöscht.',
confirmButton: 'Löschen',
onConfirm: () {
bloc.removeCustomEvent(event.id).then(completer.complete).onError((Object error, StackTrace stack) {
completer.completeError(error, stack);
});
bloc.removeCustomEvent(event.id).then(completer.complete).onError(completer.completeError);
},
).asDialog(context);
return completer;
+8 -3
View File
@@ -8,6 +8,7 @@ import '../../../state/app/infrastructure/loadableState/view/loadable_state_cons
import '../../../state/app/modules/timetable/bloc/timetable_bloc.dart';
import '../../../state/app/modules/timetable/bloc/timetable_state.dart';
import '../../../state/app/modules/settings/bloc/settings_cubit.dart';
import '../../../storage/timetable_settings.dart';
import 'custom_events/custom_event_edit_dialog.dart';
import 'data/arbitrary_appointment.dart';
import 'data/lesson_period_schedule.dart';
@@ -30,6 +31,7 @@ class _TimetableState extends State<Timetable> {
List<Appointment>? _cachedAppointments;
int? _lastDataVersion;
TimetableSettings? _lastTimetableSettings;
DateTime _initialDisplayDate() => DateTime.now().add(const Duration(days: 2));
@@ -51,18 +53,21 @@ class _TimetableState extends State<Timetable> {
}
List<Appointment> _appointments(TimetableState state) {
if (_cachedAppointments != null && _lastDataVersion == state.dataVersion) {
final timetableSettings = context.watch<SettingsCubit>().val().timetableSettings;
if (_cachedAppointments != null &&
_lastDataVersion == state.dataVersion &&
identical(_lastTimetableSettings, timetableSettings)) {
return _cachedAppointments!;
}
_lastDataVersion = state.dataVersion;
_lastTimetableSettings = timetableSettings;
final settings = context.read<SettingsCubit>();
return _cachedAppointments = TimetableAppointmentFactory(
lessons: state.getAllKnownLessons().toList(),
customEvents: state.customEvents?.events ?? const [],
rooms: state.rooms!,
subjects: state.subjects!,
settings: settings.val().timetableSettings,
settings: timetableSettings,
now: DateTime.now(),
).build();
}
+35
View File
@@ -0,0 +1,35 @@
import 'package:flutter/material.dart';
class AppProgressIndicator extends StatelessWidget {
final double size;
final double strokeWidth;
final Color? color;
const AppProgressIndicator._({
required this.size,
required this.strokeWidth,
this.color,
});
const AppProgressIndicator.small({Color? color})
: this._(size: 16, strokeWidth: 2, color: color);
const AppProgressIndicator.medium({Color? color})
: this._(size: 24, strokeWidth: 2.5, color: color);
const AppProgressIndicator.large({Color? color})
: this._(size: 40, strokeWidth: 3, color: color);
@override
Widget build(BuildContext context) {
final resolved = color ?? Theme.of(context).colorScheme.primary;
return SizedBox(
width: size,
height: size,
child: CircularProgressIndicator(
strokeWidth: strokeWidth,
valueColor: AlwaysStoppedAnimation<Color>(resolved),
),
);
}
}
+541
View File
@@ -0,0 +1,541 @@
import 'package:flutter/material.dart';
import '../api/errors/error_mapper.dart';
import 'app_progress_indicator.dart';
import 'info_dialog.dart';
Future<bool> runWithErrorDialog(
BuildContext context,
AsyncActionCallback action, {
AsyncErrorBuilder? errorBuilder,
}) async {
try {
await action();
return true;
} catch (e) {
if (!context.mounted) return false;
final message = errorBuilder != null ? errorBuilder(e) : errorToUserMessage(e);
InfoDialog.show(context, message);
return false;
}
}
typedef AsyncActionCallback = Future<void> Function();
typedef AsyncErrorBuilder = String Function(Object error);
class AsyncActionController extends ChangeNotifier {
bool _busy = false;
String? _error;
bool get busy => _busy;
String? get error => _error;
Future<bool> run(
AsyncActionCallback action, {
AsyncErrorBuilder? errorBuilder,
}) async {
if (_busy) return false;
_busy = true;
_error = null;
notifyListeners();
try {
await action();
_busy = false;
notifyListeners();
return true;
} catch (e) {
_busy = false;
_error = errorBuilder != null ? errorBuilder(e) : errorToUserMessage(e);
notifyListeners();
return false;
}
}
void clearError() {
if (_error == null) return;
_error = null;
notifyListeners();
}
}
class _AsyncMixin extends StatefulWidget {
final AsyncActionCallback? onPressed;
final AsyncActionController? controller;
final AsyncErrorBuilder? errorBuilder;
final void Function(String message)? onError;
final VoidCallback? onSuccess;
final Widget Function(BuildContext context, bool busy, VoidCallback? handler) builder;
const _AsyncMixin({
required this.onPressed,
required this.builder,
this.controller,
this.errorBuilder,
this.onError,
this.onSuccess,
});
@override
State<_AsyncMixin> createState() => _AsyncMixinState();
}
class _AsyncMixinState extends State<_AsyncMixin> {
late final AsyncActionController _internal;
AsyncActionController get _controller => widget.controller ?? _internal;
@override
void initState() {
super.initState();
if (widget.controller == null) {
_internal = AsyncActionController();
}
_controller.addListener(_onControllerChange);
}
@override
void didUpdateWidget(covariant _AsyncMixin oldWidget) {
super.didUpdateWidget(oldWidget);
if (oldWidget.controller != widget.controller) {
(oldWidget.controller ?? _internal).removeListener(_onControllerChange);
_controller.addListener(_onControllerChange);
}
}
@override
void dispose() {
_controller.removeListener(_onControllerChange);
if (widget.controller == null) {
_internal.dispose();
}
super.dispose();
}
void _onControllerChange() {
if (mounted) setState(() {});
}
Future<void> _trigger() async {
final action = widget.onPressed;
if (action == null) return;
final success = await _controller.run(action, errorBuilder: widget.errorBuilder);
if (!mounted) return;
if (success) {
widget.onSuccess?.call();
} else if (widget.onError != null && _controller.error != null) {
widget.onError!(_controller.error!);
}
}
@override
Widget build(BuildContext context) {
final handler = widget.onPressed == null ? null : _trigger;
return widget.builder(context, _controller.busy, _controller.busy ? null : handler);
}
}
class AsyncActionButton extends StatelessWidget {
final AsyncActionCallback? onPressed;
final Widget child;
final IconData? icon;
final ButtonStyle? style;
final AsyncActionController? controller;
final AsyncErrorBuilder? errorBuilder;
final void Function(String message)? onError;
final VoidCallback? onSuccess;
final bool showInlineError;
const AsyncActionButton({
required this.onPressed,
required this.child,
this.icon,
this.style,
this.controller,
this.errorBuilder,
this.onError,
this.onSuccess,
this.showInlineError = true,
super.key,
});
@override
Widget build(BuildContext context) => _AsyncMixin(
onPressed: onPressed,
controller: controller,
errorBuilder: errorBuilder,
onError: onError,
onSuccess: onSuccess,
builder: (context, busy, handler) {
final spinner = AppProgressIndicator.small(
color: Theme.of(context).colorScheme.onPrimary,
);
final content = busy
? Row(
mainAxisSize: MainAxisSize.min,
children: [spinner, const SizedBox(width: 8), child],
)
: (icon != null
? Row(
mainAxisSize: MainAxisSize.min,
children: [Icon(icon), const SizedBox(width: 8), child],
)
: child);
final button = ElevatedButton(
onPressed: handler,
style: style,
child: content,
);
return _withInlineError(context, button);
},
);
Widget _withInlineError(BuildContext context, Widget button) {
if (!showInlineError) return button;
return _InlineErrorWrapper(controller: controller, child: button);
}
}
class AsyncTextButton extends StatelessWidget {
final AsyncActionCallback? onPressed;
final Widget child;
final AsyncActionController? controller;
final AsyncErrorBuilder? errorBuilder;
final void Function(String message)? onError;
final VoidCallback? onSuccess;
final bool showInlineError;
const AsyncTextButton({
required this.onPressed,
required this.child,
this.controller,
this.errorBuilder,
this.onError,
this.onSuccess,
this.showInlineError = true,
super.key,
});
@override
Widget build(BuildContext context) => _AsyncMixin(
onPressed: onPressed,
controller: controller,
errorBuilder: errorBuilder,
onError: onError,
onSuccess: onSuccess,
builder: (context, busy, handler) {
final content = busy
? Row(
mainAxisSize: MainAxisSize.min,
children: [
AppProgressIndicator.small(
color: Theme.of(context).colorScheme.primary,
),
const SizedBox(width: 8),
child,
],
)
: child;
return _InlineErrorWrapper(
controller: controller,
child: TextButton(onPressed: handler, child: content),
);
},
);
}
class AsyncIconButton extends StatelessWidget {
final AsyncActionCallback? onPressed;
final IconData icon;
final Color? color;
final String? tooltip;
final AsyncActionController? controller;
final AsyncErrorBuilder? errorBuilder;
final void Function(String message)? onError;
final VoidCallback? onSuccess;
const AsyncIconButton({
required this.onPressed,
required this.icon,
this.color,
this.tooltip,
this.controller,
this.errorBuilder,
this.onError,
this.onSuccess,
super.key,
});
@override
Widget build(BuildContext context) => _AsyncMixin(
onPressed: onPressed,
controller: controller,
errorBuilder: errorBuilder,
onError: onError,
onSuccess: onSuccess,
builder: (context, busy, handler) {
if (busy) {
return Padding(
padding: const EdgeInsets.all(12),
child: AppProgressIndicator.small(color: color),
);
}
return IconButton(
icon: Icon(icon, color: color),
tooltip: tooltip,
onPressed: handler,
);
},
);
}
class AsyncFab extends StatelessWidget {
final AsyncActionCallback? onPressed;
final IconData icon;
final Color? backgroundColor;
final Color? foregroundColor;
final Object? heroTag;
final AsyncActionController? controller;
final AsyncErrorBuilder? errorBuilder;
final void Function(String message)? onError;
final VoidCallback? onSuccess;
final bool mini;
const AsyncFab({
required this.onPressed,
required this.icon,
this.backgroundColor,
this.foregroundColor,
this.heroTag,
this.controller,
this.errorBuilder,
this.onError,
this.onSuccess,
this.mini = false,
super.key,
});
@override
Widget build(BuildContext context) => _AsyncMixin(
onPressed: onPressed,
controller: controller,
errorBuilder: errorBuilder,
onError: onError,
onSuccess: onSuccess,
builder: (context, busy, handler) {
final fg = foregroundColor ?? Theme.of(context).colorScheme.onPrimary;
return FloatingActionButton(
heroTag: heroTag,
backgroundColor: backgroundColor,
foregroundColor: fg,
mini: mini,
onPressed: handler,
child: busy ? AppProgressIndicator.small(color: fg) : Icon(icon),
);
},
);
}
class AsyncListTile extends StatefulWidget {
final AsyncActionCallback onPressed;
final Widget? leading;
final Widget title;
final Widget? subtitle;
final bool closeOnSuccess;
final VoidCallback? onSuccess;
final AsyncErrorBuilder? errorBuilder;
final bool enabled;
const AsyncListTile({
required this.onPressed,
required this.title,
this.leading,
this.subtitle,
this.closeOnSuccess = true,
this.onSuccess,
this.errorBuilder,
this.enabled = true,
super.key,
});
@override
State<AsyncListTile> createState() => _AsyncListTileState();
}
class _AsyncListTileState extends State<AsyncListTile> {
final AsyncActionController _controller = AsyncActionController();
@override
void dispose() {
_controller.dispose();
super.dispose();
}
Future<void> _handleTap() async {
final ok = await _controller.run(widget.onPressed, errorBuilder: widget.errorBuilder);
if (!mounted) return;
if (ok) {
widget.onSuccess?.call();
if (widget.closeOnSuccess && Navigator.of(context).canPop()) {
Navigator.of(context).pop();
}
}
}
@override
Widget build(BuildContext context) => AnimatedBuilder(
animation: _controller,
builder: (context, _) {
final busy = _controller.busy;
final err = _controller.error;
final leading = busy
? const SizedBox(
width: 24,
height: 24,
child: AppProgressIndicator.small(),
)
: widget.leading;
return Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
ListTile(
leading: leading,
title: widget.title,
subtitle: widget.subtitle,
enabled: widget.enabled && !busy,
onTap: busy ? null : _handleTap,
),
if (err != null)
Padding(
padding: const EdgeInsets.only(left: 16, right: 16, bottom: 8),
child: Text(
err,
style: TextStyle(color: Theme.of(context).colorScheme.error, fontSize: 13),
),
),
],
);
},
);
}
class _InlineErrorWrapper extends StatelessWidget {
final AsyncActionController? controller;
final Widget child;
const _InlineErrorWrapper({required this.controller, required this.child});
@override
Widget build(BuildContext context) {
final c = controller;
if (c == null) return child;
return AnimatedBuilder(
animation: c,
builder: (context, _) {
final err = c.error;
return Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.center,
children: [
child,
if (err != null) ...[
const SizedBox(height: 8),
Text(
err,
textAlign: TextAlign.center,
style: TextStyle(color: Theme.of(context).colorScheme.error, fontSize: 13),
),
],
],
);
},
);
}
}
class AsyncDialogAction extends StatefulWidget {
final String confirmLabel;
final AsyncActionCallback onConfirm;
final String? cancelLabel;
final AsyncErrorBuilder? errorBuilder;
final ButtonStyle? confirmStyle;
const AsyncDialogAction({
required this.confirmLabel,
required this.onConfirm,
this.cancelLabel = 'Abbrechen',
this.errorBuilder,
this.confirmStyle,
super.key,
});
@override
State<AsyncDialogAction> createState() => _AsyncDialogActionState();
}
class _AsyncDialogActionState extends State<AsyncDialogAction> {
final AsyncActionController _controller = AsyncActionController();
@override
void dispose() {
_controller.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) => AnimatedBuilder(
animation: _controller,
builder: (context, _) {
final err = _controller.error;
return Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
if (err != null)
Padding(
padding: const EdgeInsets.only(bottom: 8),
child: Text(
err,
textAlign: TextAlign.center,
style: TextStyle(color: Theme.of(context).colorScheme.error, fontSize: 13),
),
),
Row(
mainAxisAlignment: MainAxisAlignment.end,
children: [
if (widget.cancelLabel != null)
TextButton(
onPressed: _controller.busy ? null : () => Navigator.of(context).pop(),
child: Text(widget.cancelLabel!),
),
TextButton(
style: widget.confirmStyle,
onPressed: _controller.busy
? null
: () async {
final ok = await _controller.run(
widget.onConfirm,
errorBuilder: widget.errorBuilder,
);
if (ok && context.mounted) {
Navigator.of(context).pop(true);
}
},
child: _controller.busy
? Row(
mainAxisSize: MainAxisSize.min,
children: [
AppProgressIndicator.small(
color: Theme.of(context).colorScheme.primary,
),
const SizedBox(width: 8),
Text(widget.confirmLabel),
],
)
: Text(widget.confirmLabel),
),
],
),
],
);
},
);
}
+44 -15
View File
@@ -1,14 +1,30 @@
import 'package:flutter/material.dart';
import 'package:url_launcher/url_launcher.dart';
import 'async_action_button.dart';
class ConfirmDialog extends StatelessWidget {
final String title;
final String content;
final IconData? icon;
final String confirmButton;
final String cancelButton;
final void Function() onConfirm;
const ConfirmDialog({super.key, required this.title, this.content = '', this.icon, this.confirmButton = 'Ok', this.cancelButton = 'Abbrechen', required this.onConfirm});
final void Function()? onConfirm;
final AsyncActionCallback? onConfirmAsync;
final AsyncErrorBuilder? errorBuilder;
const ConfirmDialog({
super.key,
required this.title,
this.content = '',
this.icon,
this.confirmButton = 'Ok',
this.cancelButton = 'Abbrechen',
this.onConfirm,
this.onConfirmAsync,
this.errorBuilder,
}) : assert(onConfirm != null || onConfirmAsync != null,
'ConfirmDialog requires either onConfirm or onConfirmAsync');
void asDialog(BuildContext context) {
showDialog(context: context, builder: build);
@@ -16,19 +32,32 @@ class ConfirmDialog extends StatelessWidget {
@override
Widget build(BuildContext context) => AlertDialog(
icon: icon != null ? Icon(icon) : null,
title: Text(title),
content: Text(content),
actions: [
TextButton(onPressed: () {
Navigator.of(context).pop();
}, child: Text(cancelButton)),
TextButton(onPressed: () {
Navigator.of(context).pop();
onConfirm();
}, child: Text(confirmButton)),
],
);
icon: icon != null ? Icon(icon) : null,
title: Text(title),
content: Text(content),
actions: onConfirmAsync != null
? [
AsyncDialogAction(
confirmLabel: confirmButton,
cancelLabel: cancelButton,
onConfirm: onConfirmAsync!,
errorBuilder: errorBuilder,
),
]
: [
TextButton(
onPressed: () => Navigator.of(context).pop(),
child: Text(cancelButton),
),
TextButton(
onPressed: () {
Navigator.of(context).pop();
onConfirm!();
},
child: Text(confirmButton),
),
],
);
static void openBrowser(BuildContext context, String url) {
showDialog(