From 4b1d4379a027257a667620f51641043435628ff4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Elias=20M=C3=BCller?= Date: Wed, 6 May 2026 10:11:45 +0200 Subject: [PATCH] loading state and error handling refactor --- lib/api/errors/app_exception.dart | 16 + lib/api/errors/auth_exception.dart | 23 + lib/api/errors/error_mapper.dart | 64 +++ lib/api/errors/network_exception.dart | 13 + lib/api/errors/not_found_exception.dart | 8 + lib/api/errors/parse_exception.dart | 8 + lib/api/errors/server_exception.dart | 14 + lib/api/errors/talk_exception.dart | 33 ++ lib/api/errors/webuntis_exception.dart | 31 + lib/api/marianumcloud/talk/talkApi.dart | 45 +- lib/api/mhsl/mhslApi.dart | 41 +- lib/api/webuntis/webuntisApi.dart | 32 +- lib/main.dart | 33 +- .../bloc/loadable_state_bloc.dart | 6 + .../loadableState/loading_error.dart | 1 + .../loadableState/loading_error.freezed.dart | 43 +- .../view/loadable_state_consumer.dart | 7 +- .../view/loadable_state_error_bar.dart | 17 +- .../view/loadable_state_error_screen.dart | 72 ++- .../view/loadable_state_primary_loading.dart | 3 +- .../loadable_hydrated_bloc.dart | 6 +- .../app/modules/chat/bloc/chat_bloc.dart | 6 +- .../modules/chatList/bloc/chat_list_bloc.dart | 6 +- .../app/modules/files/bloc/files_bloc.dart | 6 +- .../modules/settings/bloc/settings_cubit.dart | 16 +- lib/theming/app_theme.dart | 11 + lib/view/pages/files/files.dart | 26 +- lib/view/pages/files/files_upload_dialog.dart | 2 +- .../pages/files/widgets/file_element.dart | 11 +- .../settings/sections/about_section.dart | 2 +- .../settings/sections/account_section.dart | 4 +- .../settings/sections/appearance_section.dart | 2 +- .../settings/sections/dev_tools_section.dart | 66 ++- .../settings/sections/files_section.dart | 2 +- .../pages/settings/sections/talk_section.dart | 2 +- .../settings/sections/timetable_section.dart | 16 +- lib/view/pages/settings/settings.dart | 37 +- lib/view/pages/talk/chat_list.dart | 12 +- lib/view/pages/talk/join_chat.dart | 23 +- lib/view/pages/talk/widgets/chat_bubble.dart | 33 +- .../widgets/chat_message_options_dialog.dart | 113 ++-- .../pages/talk/widgets/chat_textfield.dart | 78 ++- lib/view/pages/talk/widgets/chat_tile.dart | 89 ++- .../details/delete_custom_event.dart | 4 +- lib/view/pages/timetable/timetable.dart | 11 +- lib/widget/app_progress_indicator.dart | 35 ++ lib/widget/async_action_button.dart | 541 ++++++++++++++++++ lib/widget/confirm_dialog.dart | 61 +- 48 files changed, 1377 insertions(+), 354 deletions(-) create mode 100644 lib/api/errors/app_exception.dart create mode 100644 lib/api/errors/auth_exception.dart create mode 100644 lib/api/errors/error_mapper.dart create mode 100644 lib/api/errors/network_exception.dart create mode 100644 lib/api/errors/not_found_exception.dart create mode 100644 lib/api/errors/parse_exception.dart create mode 100644 lib/api/errors/server_exception.dart create mode 100644 lib/api/errors/talk_exception.dart create mode 100644 lib/api/errors/webuntis_exception.dart create mode 100644 lib/widget/app_progress_indicator.dart create mode 100644 lib/widget/async_action_button.dart diff --git a/lib/api/errors/app_exception.dart b/lib/api/errors/app_exception.dart new file mode 100644 index 0000000..e90b716 --- /dev/null +++ b/lib/api/errors/app_exception.dart @@ -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)'; +} diff --git a/lib/api/errors/auth_exception.dart b/lib/api/errors/auth_exception.dart new file mode 100644 index 0000000..60a70f7 --- /dev/null +++ b/lib/api/errors/auth_exception.dart @@ -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, + ); +} diff --git a/lib/api/errors/error_mapper.dart b/lib/api/errors/error_mapper.dart new file mode 100644 index 0000000..2619643 --- /dev/null +++ b/lib/api/errors/error_mapper.dart @@ -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; +} diff --git a/lib/api/errors/network_exception.dart b/lib/api/errors/network_exception.dart new file mode 100644 index 0000000..10fbb56 --- /dev/null +++ b/lib/api/errors/network_exception.dart @@ -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, + ); +} diff --git a/lib/api/errors/not_found_exception.dart b/lib/api/errors/not_found_exception.dart new file mode 100644 index 0000000..f3d525a --- /dev/null +++ b/lib/api/errors/not_found_exception.dart @@ -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); +} diff --git a/lib/api/errors/parse_exception.dart b/lib/api/errors/parse_exception.dart new file mode 100644 index 0000000..57a3bf2 --- /dev/null +++ b/lib/api/errors/parse_exception.dart @@ -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); +} diff --git a/lib/api/errors/server_exception.dart b/lib/api/errors/server_exception.dart new file mode 100644 index 0000000..efabca2 --- /dev/null +++ b/lib/api/errors/server_exception.dart @@ -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, + ); +} diff --git a/lib/api/errors/talk_exception.dart b/lib/api/errors/talk_exception.dart new file mode 100644 index 0000000..f46c0c7 --- /dev/null +++ b/lib/api/errors/talk_exception.dart @@ -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}).'; + } + } +} diff --git a/lib/api/errors/webuntis_exception.dart b/lib/api/errors/webuntis_exception.dart new file mode 100644 index 0000000..fd35d35 --- /dev/null +++ b/lib/api/errors/webuntis_exception.dart @@ -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}).'; + } + } +} diff --git a/lib/api/marianumcloud/talk/talkApi.dart b/lib/api/marianumcloud/talk/talkApi.dart index 617ee6f..371d9f2 100644 --- a/lib/api/marianumcloud/talk/talkApi.dart +++ b/lib/api/marianumcloud/talk/talkApi.dart @@ -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 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 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'); } } } diff --git a/lib/api/mhsl/mhslApi.dart b/lib/api/mhsl/mhslApi.dart index eb4910a..55f31c1 100644 --- a/lib/api/mhsl/mhslApi.dart +++ b/lib/api/mhsl/mhslApi.dart @@ -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 extends ApiRequest { String subpath; @@ -15,18 +20,38 @@ abstract class MhslApi extends ApiRequest { T assemble(String raw); Future 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'); diff --git a/lib/api/webuntis/webuntisApi.dart b/lib/api/webuntis/webuntisApi.dart index 9008949..846c1e6 100644 --- a/lib/api/webuntis/webuntisApi.dart +++ b/lib/api/webuntis/webuntisApi.dart @@ -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 post(String data, Map? headers) async => await http - .post(endpoint, body: data, headers: headers) - .timeout( - const Duration(seconds: 10), - onTimeout: () => throw WebuntisError('Timeout', 1) - ); + Future post(String data, Map? 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}'); + } + } } diff --git a/lib/main.dart b/lib/main.dart index adcc9c1..411a05c 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -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 main() async { log('MarianumMobile started'); @@ -68,9 +68,21 @@ Future 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
{ 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…'), + ], + ), + ), + ); } }, ), diff --git a/lib/state/app/infrastructure/loadableState/bloc/loadable_state_bloc.dart b/lib/state/app/infrastructure/loadableState/bloc/loadable_state_bloc.dart index b246417..ef0e167 100644 --- a/lib/state/app/infrastructure/loadableState/bloc/loadable_state_bloc.dart +++ b/lib/state/app/infrastructure/loadableState/bloc/loadable_state_bloc.dart @@ -41,6 +41,12 @@ class LoadableStateBloc extends Bloc { ? 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' diff --git a/lib/state/app/infrastructure/loadableState/loading_error.dart b/lib/state/app/infrastructure/loadableState/loading_error.dart index 9f82716..77bbf22 100644 --- a/lib/state/app/infrastructure/loadableState/loading_error.dart +++ b/lib/state/app/infrastructure/loadableState/loading_error.dart @@ -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; } diff --git a/lib/state/app/infrastructure/loadableState/loading_error.freezed.dart b/lib/state/app/infrastructure/loadableState/loading_error.freezed.dart index c4c3924..958c021 100644 --- a/lib/state/app/infrastructure/loadableState/loading_error.freezed.dart +++ b/lib/state/app/infrastructure/loadableState/loading_error.freezed.dart @@ -14,7 +14,7 @@ T _$identity(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 get copyWith => _$LoadingErrorCopyWithImpl 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 Function( String message, bool allowRetry)? $default,{required TResult orElse(),}) {final _that = this; +@optionalTypeArgs TResult maybeWhen(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 Function( String message, bool allowRetry) $default,) {final _that = this; +@optionalTypeArgs TResult when(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? Function( String message, bool allowRetry)? $default,) {final _that = this; +@optionalTypeArgs TResult? whenOrNull(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, )); } diff --git a/lib/state/app/infrastructure/loadableState/view/loadable_state_consumer.dart b/lib/state/app/infrastructure/loadableState/view/loadable_state_consumer.dart index ce45fcd..cce2287 100644 --- a/lib/state/app/infrastructure/loadableState/view/loadable_state_consumer.dart +++ b/lib/state/app/infrastructure/loadableState/view/loadable_state_consumer.dart @@ -88,6 +88,7 @@ class LoadableStateConsumer { @override Widget build(BuildContext context) { var bloc = context.watch(); + 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), + ), ], ); } diff --git a/lib/state/app/infrastructure/loadableState/view/loadable_state_error_screen.dart b/lib/state/app/infrastructure/loadableState/view/loadable_state_error_screen.dart index cb68e76..4d118bc 100644 --- a/lib/state/app/infrastructure/loadableState/view/loadable_state_error_screen.dart +++ b/lib/state/app/infrastructure/loadableState/view/loadable_state_error_screen.dart @@ -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(); + 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'), + ), + ], ], - ], + ), ), ), ); diff --git a/lib/state/app/infrastructure/loadableState/view/loadable_state_primary_loading.dart b/lib/state/app/infrastructure/loadableState/view/loadable_state_primary_loading.dart index 053aaba..6e996a6 100644 --- a/lib/state/app/infrastructure/loadableState/view/loadable_state_primary_loading.dart +++ b/lib/state/app/infrastructure/loadableState/view/loadable_state_primary_loading.dart @@ -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()), ); } diff --git a/lib/state/app/infrastructure/utilityWidgets/loadableHydratedBloc/loadable_hydrated_bloc.dart b/lib/state/app/infrastructure/utilityWidgets/loadableHydratedBloc/loadable_hydrated_bloc.dart index 67cf540..b6807b5 100644 --- a/lib/state/app/infrastructure/utilityWidgets/loadableHydratedBloc/loadable_hydrated_bloc.dart +++ b/lib/state/app/infrastructure/utilityWidgets/loadableHydratedBloc/loadable_hydrated_bloc.dart @@ -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) { diff --git a/lib/state/app/modules/chat/bloc/chat_bloc.dart b/lib/state/app/modules/chat/bloc/chat_bloc.dart index c3a45ca..5dfb29a 100644 --- a/lib/state/app/modules/chat/bloc/chat_bloc.dart +++ b/lib/state/app/modules/chat/bloc/chat_bloc.dart @@ -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 { 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; diff --git a/lib/theming/app_theme.dart b/lib/theming/app_theme.dart index cf831c0..d39f459 100644 --- a/lib/theming/app_theme.dart +++ b/lib/theming/app_theme.dart @@ -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) { diff --git a/lib/view/pages/files/files.dart b/lib/view/pages/files/files.dart index 9f98cea..212c8fc 100644 --- a/lib/view/pages/files/files.dart +++ b/lib/view/pages/files/files.dart @@ -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().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'), ), ], ), diff --git a/lib/view/pages/files/files_upload_dialog.dart b/lib/view/pages/files/files_upload_dialog.dart index d4d53fd..4c9b1c6 100644 --- a/lib/view/pages/files/files_upload_dialog.dart +++ b/lib/view/pages/files/files_upload_dialog.dart @@ -254,7 +254,7 @@ class _FilesUploadDialogState extends State { 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; diff --git a/lib/view/pages/files/widgets/file_element.dart b/lib/view/pages/files/widgets/file_element.dart index dc2c626..26439a0 100644 --- a/lib/view/pages/files/widgets/file_element.dart +++ b/lib/view/pages/files/widgets/file_element.dart @@ -159,11 +159,12 @@ class _FileElementState extends State { 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(); + }, )); }, ), diff --git a/lib/view/pages/settings/sections/about_section.dart b/lib/view/pages/settings/sections/about_section.dart index 0784a6d..e8b3157 100644 --- a/lib/view/pages/settings/sections/about_section.dart +++ b/lib/view/pages/settings/sections/about_section.dart @@ -16,7 +16,7 @@ class AboutSection extends StatelessWidget { @override Widget build(BuildContext context) { - final settings = context.read(); + final settings = context.watch(); return Column( children: [ ListTile( diff --git a/lib/view/pages/settings/sections/account_section.dart b/lib/view/pages/settings/sections/account_section.dart index b7bf522..6cc6846 100644 --- a/lib/view/pages/settings/sections/account_section.dart +++ b/lib/view/pages/settings/sections/account_section.dart @@ -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(); diff --git a/lib/view/pages/settings/sections/appearance_section.dart b/lib/view/pages/settings/sections/appearance_section.dart index f5df162..003441a 100644 --- a/lib/view/pages/settings/sections/appearance_section.dart +++ b/lib/view/pages/settings/sections/appearance_section.dart @@ -9,7 +9,7 @@ class AppearanceSection extends StatelessWidget { @override Widget build(BuildContext context) { - final settings = context.read(); + final settings = context.watch(); return ListTile( leading: const Icon(Icons.dark_mode_outlined), title: const Text('Farbgebung'), diff --git a/lib/view/pages/settings/sections/dev_tools_section.dart b/lib/view/pages/settings/sections/dev_tools_section.dart index 619392c..95d8efd 100644 --- a/lib/view/pages/settings/sections/dev_tools_section.dart +++ b/lib/view/pages/settings/sections/dev_tools_section.dart @@ -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 { 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( + 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( diff --git a/lib/view/pages/settings/sections/files_section.dart b/lib/view/pages/settings/sections/files_section.dart index 2035648..982a464 100644 --- a/lib/view/pages/settings/sections/files_section.dart +++ b/lib/view/pages/settings/sections/files_section.dart @@ -8,7 +8,7 @@ class FilesSection extends StatelessWidget { @override Widget build(BuildContext context) { - final settings = context.read(); + final settings = context.watch(); return Column( children: [ ListTile( diff --git a/lib/view/pages/settings/sections/talk_section.dart b/lib/view/pages/settings/sections/talk_section.dart index 1133c11..1596222 100644 --- a/lib/view/pages/settings/sections/talk_section.dart +++ b/lib/view/pages/settings/sections/talk_section.dart @@ -10,7 +10,7 @@ class TalkSection extends StatelessWidget { @override Widget build(BuildContext context) { - final settings = context.read(); + final settings = context.watch(); final talkSettings = settings.val().talkSettings; final notificationSettings = settings.val().notificationSettings; return Column( diff --git a/lib/view/pages/settings/sections/timetable_section.dart b/lib/view/pages/settings/sections/timetable_section.dart index a402cd9..4879c18 100644 --- a/lib/view/pages/settings/sections/timetable_section.dart +++ b/lib/view/pages/settings/sections/timetable_section.dart @@ -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(); - final timetableBloc = context.read(); + final settings = context.watch(); 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!, ), ), ], diff --git a/lib/view/pages/settings/settings.dart b/lib/view/pages/settings/settings.dart index 196d5e5..ebb45ea 100644 --- a/lib/view/pages/settings/settings.dart +++ b/lib/view/pages/settings/settings.dart @@ -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( - 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(), + ], ), ); } diff --git a/lib/view/pages/talk/chat_list.dart b/lib/view/pages/talk/chat_list.dart index 04c53d2..7037624 100644 --- a/lib/view/pages/talk/chat_list.dart +++ b/lib/view/pages/talk/chat_list.dart @@ -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) { diff --git a/lib/view/pages/talk/join_chat.dart b/lib/view/pages/talk/join_chat.dart index 3acc834..f937bc3 100644 --- a/lib/view/pages/talk/join_chat.dart +++ b/lib/view/pages/talk/join_chat.dart @@ -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 { @@ -16,17 +18,9 @@ class JoinChat extends SearchDelegate { 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 { } ); } 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()); }, ); } diff --git a/lib/view/pages/talk/widgets/chat_bubble.dart b/lib/view/pages/talk/widgets/chat_bubble.dart index e34a878..ede8081 100644 --- a/lib/view/pages/talk/widgets/chat_bubble.dart +++ b/lib/view/pages/talk/widgets/chat_bubble.dart @@ -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 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); + }); }, ), ); diff --git a/lib/view/pages/talk/widgets/chat_message_options_dialog.dart b/lib/view/pages/talk/widgets/chat_message_options_dialog.dart index 1de61f6..7f7fa39 100644 --- a/lib/view/pages/talk/widgets/chat_message_options_dialog.dart +++ b/lib/view/pages/talk/widgets/chat_message_options_dialog.dart @@ -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 = ['👍', '👎', '😆', '❤️', '👀']; @@ -78,14 +80,12 @@ Future 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().refresh(); - Navigator.of(dialogCtx).pop(); + if (dialogCtx.mounted) dialogCtx.read().refresh(); }, ), DebugTile(dialogCtx).jsonData(bubbleData.toJson()), @@ -94,7 +94,7 @@ Future 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 _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) { diff --git a/lib/view/pages/talk/widgets/chat_textfield.dart b/lib/view/pages/talk/widgets/chat_textfield.dart index 2ef4527..266958d 100644 --- a/lib/view/pages/talk/widgets/chat_textfield.dart +++ b/lib/view/pages/talk/widgets/chat_textfield.dart @@ -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 { late SettingsCubit settings; final TextEditingController _textBoxController = TextEditingController(); - bool isLoading = false; + final AsyncActionController _sendController = AsyncActionController(); + String? _sendError; void share(String shareFolder, List filePaths) { for (final element in filePaths) { @@ -92,6 +94,29 @@ class _ChatTextfieldState extends State { } } + @override + void dispose() { + _sendController.dispose(); + super.dispose(); + } + + Future _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 { 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: [ GestureDetector( onTap: () { @@ -200,36 +233,19 @@ class _ChatTextfieldState extends State { ), ), 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( + 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), + ), ), ]), ], diff --git a/lib/view/pages/talk/widgets/chat_tile.dart b/lib/view/pages/talk/widgets/chat_tile.dart index bfce9c5..b4f9c8d 100644 --- a/lib/view/pages/talk/widgets/chat_tile.dart +++ b/lib/view/pages/talk/widgets/chat_tile.dart @@ -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 { void _refreshList() => context.read().refresh(); - void setCurrentAsRead() { - SetReadMarker( + Future _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 { ), 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().setToken(widget.data.token); @@ -125,65 +126,53 @@ class _ChatTileState extends State { 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()), diff --git a/lib/view/pages/timetable/details/delete_custom_event.dart b/lib/view/pages/timetable/details/delete_custom_event.dart index 3161e93..7d29e86 100644 --- a/lib/view/pages/timetable/details/delete_custom_event.dart +++ b/lib/view/pages/timetable/details/delete_custom_event.dart @@ -15,9 +15,7 @@ Completer 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; diff --git a/lib/view/pages/timetable/timetable.dart b/lib/view/pages/timetable/timetable.dart index 20ae430..ee4ed3a 100644 --- a/lib/view/pages/timetable/timetable.dart +++ b/lib/view/pages/timetable/timetable.dart @@ -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 { List? _cachedAppointments; int? _lastDataVersion; + TimetableSettings? _lastTimetableSettings; DateTime _initialDisplayDate() => DateTime.now().add(const Duration(days: 2)); @@ -51,18 +53,21 @@ class _TimetableState extends State { } List _appointments(TimetableState state) { - if (_cachedAppointments != null && _lastDataVersion == state.dataVersion) { + final timetableSettings = context.watch().val().timetableSettings; + if (_cachedAppointments != null && + _lastDataVersion == state.dataVersion && + identical(_lastTimetableSettings, timetableSettings)) { return _cachedAppointments!; } _lastDataVersion = state.dataVersion; + _lastTimetableSettings = timetableSettings; - final settings = context.read(); 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(); } diff --git a/lib/widget/app_progress_indicator.dart b/lib/widget/app_progress_indicator.dart new file mode 100644 index 0000000..643096f --- /dev/null +++ b/lib/widget/app_progress_indicator.dart @@ -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(resolved), + ), + ); + } +} diff --git a/lib/widget/async_action_button.dart b/lib/widget/async_action_button.dart new file mode 100644 index 0000000..4a5e941 --- /dev/null +++ b/lib/widget/async_action_button.dart @@ -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 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 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 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 _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 createState() => _AsyncListTileState(); +} + +class _AsyncListTileState extends State { + final AsyncActionController _controller = AsyncActionController(); + + @override + void dispose() { + _controller.dispose(); + super.dispose(); + } + + Future _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 createState() => _AsyncDialogActionState(); +} + +class _AsyncDialogActionState extends State { + 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), + ), + ], + ), + ], + ); + }, + ); +} diff --git a/lib/widget/confirm_dialog.dart b/lib/widget/confirm_dialog.dart index 8e2f01f..5bb6d1f 100644 --- a/lib/widget/confirm_dialog.dart +++ b/lib/widget/confirm_dialog.dart @@ -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,20 +32,33 @@ 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( context: context,