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:developer';
import 'dart:io';
import 'package:http/http.dart' as http; import 'package:http/http.dart' as http;
import '../../apiError.dart';
import '../../apiParams.dart'; import '../../apiParams.dart';
import '../../apiRequest.dart'; import '../../apiRequest.dart';
import '../../apiResponse.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'; import '../nextcloud_ocs.dart';
enum TalkApiMethod { 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 endpoint = NextcloudOcs.uri('apps/spreed/api/$path', queryParameters: getParameters);
final mergedHeaders = {...NextcloudOcs.headers(), ...?headers}; final mergedHeaders = {...NextcloudOcs.headers(), ...?headers};
http.Response? data; final http.Response data;
try { try {
data = await request(endpoint, body, mergedHeaders); final raw = await request(endpoint, body, mergedHeaders);
if (data == null) throw Exception('No response Data'); if (raw == null) {
if (data.statusCode >= 400 || data.statusCode < 200) { throw const NetworkException(
throw Exception("Response status code '${data.statusCode}' might indicate an error"); userMessage: 'Keine Antwort vom Talk-Server erhalten.',
technicalDetails: 'Talk request returned null',
);
} }
} catch (e) { data = raw;
log(e.toString()); } on SocketException catch (e) {
throw ApiError('Request $endpoint could not be dispatched: ${e.toString()}'); 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 { try {
@@ -49,10 +71,7 @@ abstract class TalkApi<T extends ApiResponse?> extends ApiRequest {
assembled?.headers = data.headers; assembled?.headers = data.headers;
return assembled; return assembled;
} catch (e) { } catch (e) {
final message = 'Error assembling Talk API ${T.toString()} message: ${e.toString()}' throw ParseException(technicalDetails: 'Talk $endpoint assemble: $e');
' response with request body: $body and request headers: $mergedHeaders';
log(message);
throw Exception(message);
} }
} }
} }
+31 -6
View File
@@ -1,9 +1,14 @@
import 'dart:async';
import 'dart:convert'; import 'dart:convert';
import 'dart:io';
import 'package:http/http.dart' as http; import 'package:http/http.dart' as http;
import 'package:jiffy/jiffy.dart'; import 'package:jiffy/jiffy.dart';
import '../apiError.dart';
import '../apiRequest.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 { abstract class MhslApi<T> extends ApiRequest {
String subpath; String subpath;
@@ -15,18 +20,38 @@ abstract class MhslApi<T> extends ApiRequest {
T assemble(String raw); T assemble(String raw);
Future<T> run() async { 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); final http.Response data;
if(data == null) { try {
throw ApiError('Request could not be dispatched!'); 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) { if (data.statusCode > 299) {
throw ApiError('Non 200 Status code from mhsl services: $subpath: ${data.statusCode}'); throw ServerException(
statusCode: data.statusCode,
technicalDetails: 'mhsl $subpath HTTP ${data.statusCode}',
);
} }
try {
return assemble(utf8.decode(data.bodyBytes)); 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'); static String dateTimeToJson(DateTime time) => Jiffy.parseFromDateTime(time).format(pattern: 'yyyy-MM-dd HH:mm:ss');
+22 -6
View File
@@ -1,10 +1,15 @@
import 'dart:async';
import 'dart:convert'; import 'dart:convert';
import 'dart:io';
import 'package:http/http.dart' as http; import 'package:http/http.dart' as http;
import '../../model/endpoint_data.dart'; import '../../model/endpoint_data.dart';
import '../apiParams.dart'; import '../apiParams.dart';
import '../apiRequest.dart'; import '../apiRequest.dart';
import '../apiResponse.dart'; import '../apiResponse.dart';
import '../errors/network_exception.dart';
import '../errors/parse_exception.dart';
import 'queries/authenticate/authenticate.dart'; import 'queries/authenticate/authenticate.dart';
import 'webuntisError.dart'; import 'webuntisError.dart';
@@ -29,10 +34,15 @@ abstract class WebuntisApi extends ApiRequest {
var data = await post(query, {'Cookie': 'JSESSIONID=$sessionId'}); var data = await post(query, {'Cookie': 'JSESSIONID=$sessionId'});
response = data; 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'] != null) {
if(jsonData['error']['code'] == -8520) { 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(); await Authenticate.createSession();
return await this.query(untis, retry: true); return await this.query(untis, retry: true);
} else { } else {
@@ -51,10 +61,16 @@ abstract class WebuntisApi extends ApiRequest {
String _body() => genericParam == null ? '{}' : jsonEncode(genericParam); String _body() => genericParam == null ? '{}' : jsonEncode(genericParam);
Future<http.Response> post(String data, Map<String, String>? headers) async => await http Future<http.Response> post(String data, Map<String, String>? headers) async {
.post(endpoint, body: data, headers: headers) try {
.timeout( return await http.post(endpoint, body: data, headers: headers).timeout(
const Duration(seconds: 10), const Duration(seconds: 10),
onTimeout: () => throw WebuntisError('Timeout', 1) 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/dark_app_theme.dart';
import 'theming/light_app_theme.dart'; import 'theming/light_app_theme.dart';
import 'view/login/login.dart'; import 'view/login/login.dart';
import 'widget/placeholder_view.dart'; import 'widget/app_progress_indicator.dart';
Future<void> main() async { Future<void> main() async {
log('MarianumMobile started'); log('MarianumMobile started');
@@ -68,9 +68,21 @@ Future<void> main() async {
); );
if (kReleaseMode) { if (kReleaseMode) {
ErrorWidget.builder = (error) => PlaceholderView( ErrorWidget.builder = (error) => Material(
icon: Icons.phonelink_erase_rounded, color: Colors.white,
text: error.toStringShort(), 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: case AccountStatus.loggedOut:
return const Login(); return const Login();
case AccountStatus.undefined: 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 ? Colors.grey.shade600
: Theme.of(context).primaryColor; : 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() String connectionText({int? lastUpdated}) => connectivityStatusKnown()
? isConnected() ? isConnected()
? 'Verbindung fehlgeschlagen' ? 'Verbindung fehlgeschlagen'
@@ -6,6 +6,7 @@ part 'loading_error.freezed.dart';
abstract class LoadingError with _$LoadingError { abstract class LoadingError with _$LoadingError {
const factory LoadingError({ const factory LoadingError({
required String message, required String message,
String? technicalDetails,
@Default(false) bool allowRetry, @Default(false) bool allowRetry,
}) = _LoadingError; }) = _LoadingError;
} }
@@ -14,7 +14,7 @@ T _$identity<T>(T value) => value;
/// @nodoc /// @nodoc
mixin _$LoadingError { mixin _$LoadingError {
String get message; bool get allowRetry; String get message; String? get technicalDetails; bool get allowRetry;
/// Create a copy of LoadingError /// Create a copy of LoadingError
/// with the given fields replaced by the non-null parameter values. /// with the given fields replaced by the non-null parameter values.
@JsonKey(includeFromJson: false, includeToJson: false) @JsonKey(includeFromJson: false, includeToJson: false)
@@ -25,16 +25,16 @@ $LoadingErrorCopyWith<LoadingError> get copyWith => _$LoadingErrorCopyWithImpl<L
@override @override
bool operator ==(Object other) { 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 @override
int get hashCode => Object.hash(runtimeType,message,allowRetry); int get hashCode => Object.hash(runtimeType,message,technicalDetails,allowRetry);
@override @override
String toString() { 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; factory $LoadingErrorCopyWith(LoadingError value, $Res Function(LoadingError) _then) = _$LoadingErrorCopyWithImpl;
@useResult @useResult
$Res call({ $Res call({
String message, bool allowRetry String message, String? technicalDetails, bool allowRetry
}); });
@@ -62,10 +62,11 @@ class _$LoadingErrorCopyWithImpl<$Res>
/// Create a copy of LoadingError /// Create a copy of LoadingError
/// with the given fields replaced by the non-null parameter values. /// 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( return _then(_self.copyWith(
message: null == message ? _self.message : message // ignore: cast_nullable_to_non_nullable 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, 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) { switch (_that) {
case _LoadingError() when $default != null: case _LoadingError() when $default != null:
return $default(_that.message,_that.allowRetry);case _: return $default(_that.message,_that.technicalDetails,_that.allowRetry);case _:
return orElse(); 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) { switch (_that) {
case _LoadingError(): case _LoadingError():
return $default(_that.message,_that.allowRetry);case _: return $default(_that.message,_that.technicalDetails,_that.allowRetry);case _:
throw StateError('Unexpected subclass'); 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) { switch (_that) {
case _LoadingError() when $default != null: case _LoadingError() when $default != null:
return $default(_that.message,_that.allowRetry);case _: return $default(_that.message,_that.technicalDetails,_that.allowRetry);case _:
return null; return null;
} }
@@ -207,10 +208,11 @@ return $default(_that.message,_that.allowRetry);case _:
class _LoadingError implements LoadingError { 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 message;
@override final String? technicalDetails;
@override@JsonKey() final bool allowRetry; @override@JsonKey() final bool allowRetry;
/// Create a copy of LoadingError /// Create a copy of LoadingError
@@ -223,16 +225,16 @@ _$LoadingErrorCopyWith<_LoadingError> get copyWith => __$LoadingErrorCopyWithImp
@override @override
bool operator ==(Object other) { 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 @override
int get hashCode => Object.hash(runtimeType,message,allowRetry); int get hashCode => Object.hash(runtimeType,message,technicalDetails,allowRetry);
@override @override
String toString() { 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; factory _$LoadingErrorCopyWith(_LoadingError value, $Res Function(_LoadingError) _then) = __$LoadingErrorCopyWithImpl;
@override @useResult @override @useResult
$Res call({ $Res call({
String message, bool allowRetry String message, String? technicalDetails, bool allowRetry
}); });
@@ -260,10 +262,11 @@ class __$LoadingErrorCopyWithImpl<$Res>
/// Create a copy of LoadingError /// Create a copy of LoadingError
/// with the given fields replaced by the non-null parameter values. /// 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( return _then(_LoadingError(
message: null == message ? _self.message : message // ignore: cast_nullable_to_non_nullable 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, as bool,
)); ));
} }
@@ -88,6 +88,7 @@ class LoadableStateConsumer<TController extends Bloc<LoadableHydratedBlocEvent<T
visible: showErrorBar, visible: showErrorBar,
hasContent: hasContent, hasContent: hasContent,
message: loadableState.error?.message, message: loadableState.error?.message,
technicalDetails: loadableState.error?.technicalDetails,
lastUpdated: loadableState.lastFetch, lastUpdated: loadableState.lastFetch,
), ),
Expanded( Expanded(
@@ -95,7 +96,11 @@ class LoadableStateConsumer<TController extends Bloc<LoadableHydratedBlocEvent<T
children: [ children: [
LoadableStatePrimaryLoading(visible: showPrimaryLoading), LoadableStatePrimaryLoading(visible: showPrimaryLoading),
LoadableStateBackgroundLoading(visible: showBackgroundLoading), LoadableStateBackgroundLoading(visible: showBackgroundLoading),
LoadableStateErrorScreen(visible: showError, message: loadableState.error?.message), LoadableStateErrorScreen(
visible: showError,
message: loadableState.error?.message,
technicalDetails: loadableState.error?.technicalDetails,
),
AnimatedOpacity( AnimatedOpacity(
opacity: hasContent ? 1.0 : 0.0, opacity: hasContent ? 1.0 : 0.0,
@@ -10,11 +10,13 @@ class LoadableStateErrorBar extends StatelessWidget {
final bool visible; final bool visible;
final bool hasContent; final bool hasContent;
final String? message; final String? message;
final String? technicalDetails;
final int? lastUpdated; final int? lastUpdated;
const LoadableStateErrorBar({ const LoadableStateErrorBar({
required this.visible, required this.visible,
this.hasContent = false, this.hasContent = false,
this.message, this.message,
this.technicalDetails,
this.lastUpdated, this.lastUpdated,
super.key, super.key,
}); });
@@ -48,7 +50,12 @@ class LoadableStateErrorBar extends StatelessWidget {
return InkWell( return InkWell(
onTap: () { onTap: () {
if(!bloc.isConnected()) return; 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( child: Container(
height: 20, height: 20,
@@ -85,13 +92,17 @@ class _LoadableStateErrorBarTextState extends State<LoadableStateErrorBarText> {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
var bloc = context.watch<LoadableStateBloc>(); var bloc = context.watch<LoadableStateBloc>();
final foreground = bloc.connectionForegroundColor(context);
return Row( return Row(
mainAxisAlignment: MainAxisAlignment.center, mainAxisAlignment: MainAxisAlignment.center,
children: [ children: [
Icon(bloc.connectionIcon(), size: 14), Icon(bloc.connectionIcon(), size: 14, color: foreground),
const SizedBox(width: 10), 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,51 +1,71 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:provider/provider.dart'; import 'package:provider/provider.dart';
import '../../../../../widget/info_dialog.dart';
import '../bloc/loadable_state_bloc.dart'; import '../bloc/loadable_state_bloc.dart';
import 'loadable_state_consumer.dart'; import 'loadable_state_consumer.dart';
class LoadableStateErrorScreen extends StatelessWidget { class LoadableStateErrorScreen extends StatelessWidget {
final bool visible; final bool visible;
final String? message; 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 @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final bloc = context.watch<LoadableStateBloc>(); final bloc = context.watch<LoadableStateBloc>();
final isOffline = bloc.connectivityStatusKnown() && !bloc.isConnected();
final headline = isOffline ? bloc.connectionText() : (message ?? bloc.connectionText());
return AnimatedOpacity( return AnimatedOpacity(
opacity: visible ? 1.0 : 0.0, opacity: visible ? 1.0 : 0.0,
duration: LoadableStateConsumer.animationDuration, duration: LoadableStateConsumer.animationDuration,
curve: Curves.easeInOut, curve: Curves.easeInOut,
child: !visible ? null : Center( child: !visible ? null : Center(
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 24),
child: Column( child: Column(
mainAxisAlignment: MainAxisAlignment.center, mainAxisAlignment: MainAxisAlignment.center,
crossAxisAlignment: CrossAxisAlignment.center, crossAxisAlignment: CrossAxisAlignment.center,
children: [ children: [
Icon(bloc.connectionIcon(), size: 40), Icon(bloc.connectionIcon(), size: 40),
const SizedBox(height: 10), const SizedBox(height: 12),
Text(bloc.connectionText(), style: const TextStyle(fontSize: 20)), 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()) ...[ if (bloc.allowRetry()) ...[
const SizedBox(height: 10), const SizedBox(height: 16),
TextButton(onPressed: () => bloc.reFetch!(), child: const Text('Erneut versuschen')), TextButton(
const SizedBox(height: 40), onPressed: () => bloc.reFetch!(),
Padding( child: const Text('Erneut versuchen'),
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,
), ),
],
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 'package:flutter/material.dart';
import '../../../../../widget/app_progress_indicator.dart';
import 'loadable_state_consumer.dart'; import 'loadable_state_consumer.dart';
class LoadableStatePrimaryLoading extends StatelessWidget { class LoadableStatePrimaryLoading extends StatelessWidget {
@@ -11,6 +12,6 @@ class LoadableStatePrimaryLoading extends StatelessWidget {
opacity: visible ? 1.0 : 0.0, opacity: visible ? 1.0 : 0.0,
duration: LoadableStateConsumer.animationDuration, duration: LoadableStateConsumer.animationDuration,
curve: Curves.easeInOut, 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 'package:hydrated_bloc/hydrated_bloc.dart';
import '../../../../../api/errors/error_mapper.dart';
import '../../loadableState/loading_error.dart'; import '../../loadableState/loading_error.dart';
import '../../repository/repository.dart'; import '../../repository/repository.dart';
import 'loadable_hydrated_bloc_event.dart'; import 'loadable_hydrated_bloc_event.dart';
@@ -78,8 +79,9 @@ abstract class LoadableHydratedBloc<
(e) { (e) {
log('Error while fetching ${TState.toString()}: ${e.toString()}'); log('Error while fetching ${TState.toString()}: ${e.toString()}');
add(Error(LoadingError( add(Error(LoadingError(
message: e.message ?? e.toString(), message: errorToUserMessage(e),
allowRetry: true, technicalDetails: errorToTechnicalDetails(e),
allowRetry: errorAllowsRetry(e),
))); )));
}, },
).then((value) { ).then((value) {
@@ -1,3 +1,4 @@
import '../../../../../api/errors/error_mapper.dart';
import '../../../../../api/marianumcloud/talk/chat/getChatResponse.dart'; import '../../../../../api/marianumcloud/talk/chat/getChatResponse.dart';
import '../../../infrastructure/loadableState/loading_error.dart'; import '../../../infrastructure/loadableState/loading_error.dart';
import '../../../infrastructure/utilityWidgets/loadableHydratedBloc/loadable_hydrated_bloc.dart'; import '../../../infrastructure/utilityWidgets/loadableHydratedBloc/loadable_hydrated_bloc.dart';
@@ -75,8 +76,9 @@ class ChatBloc extends LoadableHydratedBloc<ChatEvent, ChatState, ChatRepository
} }
if (capturedError != null) { if (capturedError != null) {
add(Error(LoadingError( add(Error(LoadingError(
message: capturedError.toString(), message: errorToUserMessage(capturedError),
allowRetry: true, technicalDetails: errorToTechnicalDetails(capturedError),
allowRetry: errorAllowsRetry(capturedError),
))); )));
} }
} }
@@ -1,5 +1,6 @@
import 'package:flutter_app_badge/flutter_app_badge.dart'; import 'package:flutter_app_badge/flutter_app_badge.dart';
import '../../../../../api/errors/error_mapper.dart';
import '../../../infrastructure/loadableState/loading_error.dart'; import '../../../infrastructure/loadableState/loading_error.dart';
import '../../../infrastructure/utilityWidgets/loadableHydratedBloc/loadable_hydrated_bloc.dart'; import '../../../infrastructure/utilityWidgets/loadableHydratedBloc/loadable_hydrated_bloc.dart';
import '../../../infrastructure/utilityWidgets/loadableHydratedBloc/loadable_hydrated_bloc_event.dart'; import '../../../infrastructure/utilityWidgets/loadableHydratedBloc/loadable_hydrated_bloc_event.dart';
@@ -59,8 +60,9 @@ class ChatListBloc extends LoadableHydratedBloc<ChatListEvent, ChatListState, Ch
} }
if (capturedError != null) { if (capturedError != null) {
add(Error(LoadingError( add(Error(LoadingError(
message: capturedError.toString(), message: errorToUserMessage(capturedError),
allowRetry: true, 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 '../../../../../api/marianumcloud/webdav/queries/listFiles/listFilesResponse.dart';
import '../../../infrastructure/loadableState/loading_error.dart'; import '../../../infrastructure/loadableState/loading_error.dart';
import '../../../infrastructure/utilityWidgets/loadableHydratedBloc/loadable_hydrated_bloc.dart'; import '../../../infrastructure/utilityWidgets/loadableHydratedBloc/loadable_hydrated_bloc.dart';
@@ -74,8 +75,9 @@ class FilesBloc extends LoadableHydratedBloc<FilesEvent, FilesState, FilesReposi
} }
if (capturedError != null) { if (capturedError != null) {
add(Error(LoadingError( add(Error(LoadingError(
message: capturedError.toString(), message: errorToUserMessage(capturedError),
allowRetry: true, technicalDetails: errorToTechnicalDetails(capturedError),
allowRetry: errorAllowsRetry(capturedError),
))); )));
} }
} }
@@ -1,3 +1,4 @@
import 'dart:async';
import 'dart:developer'; import 'dart:developer';
import 'package:easy_debounce/easy_debounce.dart'; import 'package:easy_debounce/easy_debounce.dart';
@@ -9,14 +10,23 @@ import '../../app_modules.dart';
class SettingsCubit extends HydratedCubit<Settings> { class SettingsCubit extends HydratedCubit<Settings> {
static const _debounceTag = 'settings_persist'; static const _debounceTag = 'settings_persist';
bool _emitScheduled = false;
SettingsCubit() : super(DefaultSettings.get()); SettingsCubit() : super(DefaultSettings.get());
Settings val({bool write = false}) { Settings val({bool write = false}) {
if (write) { if (write) {
// Notify listeners immediately so the UI reflects the mutation right away; // Defer the emit until the synchronous mutation on the returned object
// debounce the actual persistence to disk to avoid hammering on rapid edits. // 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(); _emitFreshInstance();
});
}
EasyDebounce.debounce(_debounceTag, const Duration(milliseconds: 500), _emitFreshInstance); EasyDebounce.debounce(_debounceTag, const Duration(milliseconds: 500), _emitFreshInstance);
} }
return state; return state;
+11
View File
@@ -2,6 +2,17 @@ import 'package:flutter/material.dart';
import '../widget/dropdown_display.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 { class AppTheme {
static DropdownDisplay getDisplayOptions(ThemeMode theme) { static DropdownDisplay getDisplayOptions(ThemeMode theme) {
switch(theme) { switch(theme) {
+10 -10
View File
@@ -2,7 +2,6 @@ import 'dart:io';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.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 'package:persistent_bottom_nav_bar_v2/persistent_bottom_nav_bar_v2.dart';
import '../../../api/marianumcloud/webdav/queries/listFiles/cacheableFile.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_bloc.dart';
import '../../../state/app/modules/files/bloc/files_state.dart'; import '../../../state/app/modules/files/bloc/files_state.dart';
import '../../../state/app/modules/settings/bloc/settings_cubit.dart'; import '../../../state/app/modules/settings/bloc/settings_cubit.dart';
import '../../../widget/async_action_button.dart';
import '../../../widget/file_pick.dart'; import '../../../widget/file_pick.dart';
import '../../../widget/placeholder_view.dart'; import '../../../widget/placeholder_view.dart';
import 'widgets/file_element.dart'; import 'widgets/file_element.dart';
@@ -175,12 +175,10 @@ class _FilesViewState extends State<_FilesView> {
foldersToTop: context.watch<SettingsCubit>().val().fileSettings.sortFoldersToTop, foldersToTop: context.watch<SettingsCubit>().val().fileSettings.sortFoldersToTop,
reversed: currentSortDirection, reversed: currentSortDirection,
); );
return LoaderOverlay( return ListView.builder(
child: ListView.builder(
padding: EdgeInsets.zero, padding: EdgeInsets.zero,
itemCount: files.length, itemCount: files.length,
itemBuilder: (context, index) => FileElement(files[index], widget.path, bloc.refresh), itemBuilder: (context, index) => FileElement(files[index], widget.path, bloc.refresh),
),
); );
}, },
), ),
@@ -233,15 +231,17 @@ class _FilesViewState extends State<_FilesView> {
content: TextField( content: TextField(
controller: inputController, controller: inputController,
decoration: const InputDecoration(labelText: 'Name'), decoration: const InputDecoration(labelText: 'Name'),
autofocus: true,
), ),
actions: [ actions: [
TextButton(onPressed: () => Navigator.of(dialogCtx).pop(), child: const Text('Abbrechen')), AsyncDialogAction(
TextButton( confirmLabel: 'Ordner erstellen',
onPressed: () { onConfirm: () async {
bloc.createFolder(inputController.text); if (inputController.text.trim().isEmpty) {
Navigator.of(dialogCtx).pop(); 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(), border: const UnderlineInputBorder(),
label: Text('Datei ${index+1}'), label: Text('Datei ${index+1}'),
errorText: currentFile.isConflicting ? 'existiert bereits' : null, errorText: currentFile.isConflicting ? 'existiert bereits' : null,
errorStyle: const TextStyle(color: Colors.red), errorStyle: TextStyle(color: Theme.of(context).colorScheme.error),
), ),
onChanged: (input) { onChanged: (input) {
currentFile.fileName = input; currentFile.fileName = input;
@@ -159,11 +159,12 @@ class _FileElementState extends State<FileElement> {
showDialog(context: context, builder: (context) => ConfirmDialog( showDialog(context: context, builder: (context) => ConfirmDialog(
title: 'Element löschen?', title: 'Element löschen?',
content: 'Das Element wird unwiederruflich gelöscht.', content: 'Das Element wird unwiederruflich gelöscht.',
onConfirm: () { confirmButton: 'Löschen',
WebdavApi.webdav onConfirmAsync: () async {
.then((value) => value.delete(PathUri.parse(widget.file.path))) final webdav = await WebdavApi.webdav;
.then((value) => widget.refetch()); await webdav.delete(PathUri.parse(widget.file.path));
} widget.refetch();
},
)); ));
}, },
), ),
@@ -16,7 +16,7 @@ class AboutSection extends StatelessWidget {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final settings = context.read<SettingsCubit>(); final settings = context.watch<SettingsCubit>();
return Column( return Column(
children: [ children: [
ListTile( ListTile(
@@ -22,11 +22,11 @@ class AccountSection extends StatelessWidget {
void _showLogoutDialog(BuildContext context) { void _showLogoutDialog(BuildContext context) {
showDialog( showDialog(
context: context, context: context,
builder: (context) => ConfirmDialog( builder: (dialogContext) => ConfirmDialog(
title: 'Abmelden?', title: 'Abmelden?',
content: 'Möchtest du dich wirklich abmelden?', content: 'Möchtest du dich wirklich abmelden?',
confirmButton: 'Abmelden', confirmButton: 'Abmelden',
onConfirm: () async { onConfirmAsync: () async {
final prefs = await SharedPreferences.getInstance(); final prefs = await SharedPreferences.getInstance();
await prefs.clear(); await prefs.clear();
PaintingBinding.instance.imageCache.clear(); PaintingBinding.instance.imageCache.clear();
@@ -9,7 +9,7 @@ class AppearanceSection extends StatelessWidget {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final settings = context.read<SettingsCubit>(); final settings = context.watch<SettingsCubit>();
return ListTile( return ListTile(
leading: const Icon(Icons.dark_mode_outlined), leading: const Icon(Icons.dark_mode_outlined),
title: const Text('Farbgebung'), title: const Text('Farbgebung'),
@@ -6,6 +6,7 @@ import 'package:flutter_bloc/flutter_bloc.dart';
import '../../../../routing/app_routes.dart'; import '../../../../routing/app_routes.dart';
import '../../../../state/app/modules/settings/bloc/settings_cubit.dart'; import '../../../../state/app/modules/settings/bloc/settings_cubit.dart';
import '../../../../storage/settings.dart' as model;
import '../../../../widget/centered_leading.dart'; import '../../../../widget/centered_leading.dart';
import '../../../../widget/confirm_dialog.dart'; import '../../../../widget/confirm_dialog.dart';
import '../../../../widget/debug/cache_view.dart'; import '../../../../widget/debug/cache_view.dart';
@@ -28,13 +29,19 @@ class _DevToolsSectionState extends State<DevToolsSection> {
title: const Text('Performance overlays'), title: const Text('Performance overlays'),
trailing: const Icon(Icons.arrow_right), trailing: const Icon(Icons.arrow_right),
onTap: () { onTap: () {
showDialog(context: context, builder: (context) => SimpleDialog( showDialog(
context: context,
builder: (dialogCtx) => BlocBuilder<SettingsCubit, model.Settings>(
bloc: widget.settings,
builder: (_, _) {
final dev = widget.settings.val().devToolsSettings;
return SimpleDialog(
children: [ children: [
ListTile( ListTile(
leading: const Icon(Icons.auto_graph_outlined), leading: const Icon(Icons.auto_graph_outlined),
title: const Text('Performance graph'), title: const Text('Performance graph'),
trailing: Checkbox( trailing: Checkbox(
value: widget.settings.val().devToolsSettings.showPerformanceOverlay, value: dev.showPerformanceOverlay,
onChanged: (e) => widget.settings.val(write: true).devToolsSettings.showPerformanceOverlay = e!, onChanged: (e) => widget.settings.val(write: true).devToolsSettings.showPerformanceOverlay = e!,
), ),
), ),
@@ -42,7 +49,7 @@ class _DevToolsSectionState extends State<DevToolsSection> {
leading: const Icon(Icons.screen_search_desktop_outlined), leading: const Icon(Icons.screen_search_desktop_outlined),
title: const Text('Indicate offscreen layers'), title: const Text('Indicate offscreen layers'),
trailing: Checkbox( trailing: Checkbox(
value: widget.settings.val().devToolsSettings.checkerboardOffscreenLayers, value: dev.checkerboardOffscreenLayers,
onChanged: (e) => widget.settings.val(write: true).devToolsSettings.checkerboardOffscreenLayers = e!, onChanged: (e) => widget.settings.val(write: true).devToolsSettings.checkerboardOffscreenLayers = e!,
), ),
), ),
@@ -50,12 +57,15 @@ class _DevToolsSectionState extends State<DevToolsSection> {
leading: const Icon(Icons.imagesearch_roller_outlined), leading: const Icon(Icons.imagesearch_roller_outlined),
title: const Text('Indicate raster cache images'), title: const Text('Indicate raster cache images'),
trailing: Checkbox( trailing: Checkbox(
value: widget.settings.val().devToolsSettings.checkerboardRasterCacheImages, value: dev.checkerboardRasterCacheImages,
onChanged: (e) => widget.settings.val(write: true).devToolsSettings.checkerboardRasterCacheImages = e!, onChanged: (e) => widget.settings.val(write: true).devToolsSettings.checkerboardRasterCacheImages = e!,
), ),
), ),
], ],
)); );
},
),
);
}, },
), ),
ListTile( ListTile(
@@ -8,7 +8,7 @@ class FilesSection extends StatelessWidget {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final settings = context.read<SettingsCubit>(); final settings = context.watch<SettingsCubit>();
return Column( return Column(
children: [ children: [
ListTile( ListTile(
@@ -10,7 +10,7 @@ class TalkSection extends StatelessWidget {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final settings = context.read<SettingsCubit>(); final settings = context.watch<SettingsCubit>();
final talkSettings = settings.val().talkSettings; final talkSettings = settings.val().talkSettings;
final notificationSettings = settings.val().notificationSettings; final notificationSettings = settings.val().notificationSettings;
return Column( return Column(
@@ -2,7 +2,6 @@ import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:flutter_bloc/flutter_bloc.dart';
import '../../../../state/app/modules/settings/bloc/settings_cubit.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'; import '../../../../view/pages/timetable/data/timetable_name_mode.dart';
class TimetableSection extends StatelessWidget { class TimetableSection extends StatelessWidget {
@@ -10,8 +9,7 @@ class TimetableSection extends StatelessWidget {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final settings = context.read<SettingsCubit>(); final settings = context.watch<SettingsCubit>();
final timetableBloc = context.read<TimetableBloc>();
final timetableSettings = settings.val().timetableSettings; final timetableSettings = settings.val().timetableSettings;
return Column( return Column(
children: [ children: [
@@ -34,10 +32,8 @@ class TimetableSection extends StatelessWidget {
), ),
)) ))
.toList(), .toList(),
onChanged: (value) { onChanged: (value) =>
settings.val(write: true).timetableSettings.timetableNameMode = value!; settings.val(write: true).timetableSettings.timetableNameMode = value!,
timetableBloc.refresh();
},
), ),
), ),
ListTile( ListTile(
@@ -45,10 +41,8 @@ class TimetableSection extends StatelessWidget {
title: const Text('Doppelstunden zusammenhängend anzeigen'), title: const Text('Doppelstunden zusammenhängend anzeigen'),
trailing: Checkbox( trailing: Checkbox(
value: timetableSettings.connectDoubleLessons, value: timetableSettings.connectDoubleLessons,
onChanged: (e) { onChanged: (e) =>
settings.val(write: true).timetableSettings.connectDoubleLessons = e!; settings.val(write: true).timetableSettings.connectDoubleLessons = e!,
timetableBloc.refresh();
},
), ),
), ),
], ],
+1 -6
View File
@@ -1,8 +1,5 @@
import 'package:flutter/material.dart'; 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/about_section.dart';
import 'sections/account_section.dart'; import 'sections/account_section.dart';
import 'sections/appearance_section.dart'; import 'sections/appearance_section.dart';
@@ -14,8 +11,7 @@ class Settings extends StatelessWidget {
const Settings({super.key}); const Settings({super.key});
@override @override
Widget build(BuildContext context) => BlocBuilder<SettingsCubit, model.Settings>( Widget build(BuildContext context) => Scaffold(
builder: (context, _) => Scaffold(
appBar: AppBar(title: const Text('Einstellungen')), appBar: AppBar(title: const Text('Einstellungen')),
body: ListView( body: ListView(
children: const [ children: const [
@@ -32,6 +28,5 @@ class Settings extends StatelessWidget {
AboutSection(), 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 '../../../notification/notify_updater.dart';
import '../../../state/app/modules/settings/bloc/settings_cubit.dart'; import '../../../state/app/modules/settings/bloc/settings_cubit.dart';
import '../../../widget/confirm_dialog.dart'; import '../../../widget/confirm_dialog.dart';
import '../../../widget/placeholder_view.dart';
import 'widgets/chat_tile.dart'; import 'widgets/chat_tile.dart';
import 'widgets/split_view_placeholder.dart'; import 'widgets/split_view_placeholder.dart';
import 'join_chat.dart'; import 'join_chat.dart';
@@ -144,9 +145,7 @@ class _ChatListViewState extends State<_ChatListView> {
title: 'Chat starten', title: 'Chat starten',
content: "Möchtest du einen Chat mit Nutzer '$username' starten?", content: "Möchtest du einen Chat mit Nutzer '$username' starten?",
confirmButton: 'Chat starten', confirmButton: 'Chat starten',
onConfirm: () { onConfirmAsync: () => bloc.createDirectChat(username),
bloc.createDirectChat(username);
},
).asDialog(context); ).asDialog(context);
}); });
}, },
@@ -164,6 +163,13 @@ class _ChatListViewState extends State<_ChatListView> {
unreadToTop: talkSettings.sortUnreadToTop, 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( return ListView(
padding: EdgeInsets.zero, padding: EdgeInsets.zero,
children: sorted.map((room) { children: sorted.map((room) {
+10 -13
View File
@@ -2,9 +2,11 @@
import 'package:async/async.dart'; import 'package:async/async.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import '../../../api/errors/error_mapper.dart';
import '../../../api/marianumcloud/autocomplete/autocompleteApi.dart'; import '../../../api/marianumcloud/autocomplete/autocompleteApi.dart';
import '../../../api/marianumcloud/autocomplete/autocompleteResponse.dart'; import '../../../api/marianumcloud/autocomplete/autocompleteResponse.dart';
import '../../../model/endpoint_data.dart'; import '../../../model/endpoint_data.dart';
import '../../../widget/app_progress_indicator.dart';
import '../../../widget/placeholder_view.dart'; import '../../../widget/placeholder_view.dart';
class JoinChat extends SearchDelegate<String> { class JoinChat extends SearchDelegate<String> {
@@ -16,17 +18,9 @@ class JoinChat extends SearchDelegate<String> {
future: future!.value, future: future!.value,
builder: (context, snapshot) { builder: (context, snapshot) {
if(snapshot.connectionState != ConnectionState.done) { if(snapshot.connectionState != ConnectionState.done) {
return Container( return const Padding(
padding: const EdgeInsets.all(10), padding: EdgeInsets.all(10),
child: const Center( child: Center(child: AppProgressIndicator.medium()),
child: SizedBox(
height: 20,
width: 20,
child: CircularProgressIndicator(
strokeWidth: 3,
),
),
),
); );
} }
return const SizedBox.shrink(); return const SizedBox.shrink();
@@ -76,10 +70,13 @@ class JoinChat extends SearchDelegate<String> {
} }
); );
} else if(snapshot.hasError) { } 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());
}, },
); );
} }
+9 -8
View File
@@ -14,6 +14,7 @@ import '../../../../api/marianumcloud/talk/reactMessage/reactMessageParams.dart'
import '../../../../api/marianumcloud/talk/room/getRoomResponse.dart'; import '../../../../api/marianumcloud/talk/room/getRoomResponse.dart';
import '../../../../extensions/text.dart'; import '../../../../extensions/text.dart';
import '../../../../state/app/modules/chat/bloc/chat_bloc.dart'; import '../../../../state/app/modules/chat/bloc/chat_bloc.dart';
import '../../../../widget/async_action_button.dart';
import '../../../../widget/loading_spinner.dart'; import '../../../../widget/loading_spinner.dart';
import '../../files/widgets/file_element.dart'; import '../../files/widgets/file_element.dart';
import '../data/chat_bubble_styles.dart'; import '../data/chat_bubble_styles.dart';
@@ -306,22 +307,22 @@ class _ChatBubbleState extends State<ChatBubble> with SingleTickerProviderStateM
padding: EdgeInsets.zero, padding: EdgeInsets.zero,
backgroundColor: hasSelfReacted ? Theme.of(context).primaryColor : null, backgroundColor: hasSelfReacted ? Theme.of(context).primaryColor : null,
onPressed: () { onPressed: () {
runWithErrorDialog(context, () async {
if (hasSelfReacted) { if (hasSelfReacted) {
// Delete existing reaction await DeleteReactMessage(
DeleteReactMessage(
chatToken: widget.chatData.token, chatToken: widget.chatData.token,
messageId: widget.bubbleData.id, messageId: widget.bubbleData.id,
params: DeleteReactMessageParams(e.key), params: DeleteReactMessageParams(e.key),
).run().then((value) => widget.refetch(renew: true)); ).run();
} else { } else {
// Add reaction await ReactMessage(
ReactMessage(
chatToken: widget.chatData.token, chatToken: widget.chatData.token,
messageId: widget.bubbleData.id, messageId: widget.bubbleData.id,
params: ReactMessageParams(e.key) params: ReactMessageParams(e.key),
).run().then((value) => widget.refetch(renew: true)); ).run();
} }
widget.refetch(renew: true);
});
}, },
), ),
); );
@@ -11,6 +11,8 @@ import '../../../../api/marianumcloud/talk/reactMessage/reactMessageParams.dart'
import '../../../../api/marianumcloud/talk/room/getRoomResponse.dart'; import '../../../../api/marianumcloud/talk/room/getRoomResponse.dart';
import '../../../../routing/app_routes.dart'; import '../../../../routing/app_routes.dart';
import '../../../../state/app/modules/chat/bloc/chat_bloc.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'; import '../../../../widget/debug/debug_tile.dart';
const _commonReactions = <String>['👍', '👎', '😆', '❤️', '👀']; const _commonReactions = <String>['👍', '👎', '😆', '❤️', '👀'];
@@ -78,14 +80,12 @@ Future<void> showChatMessageOptionsDialog(
onTap: () => Navigator.of(dialogCtx).pop(), onTap: () => Navigator.of(dialogCtx).pop(),
), ),
if (canDelete) if (canDelete)
ListTile( AsyncListTile(
leading: const Icon(Icons.delete_outline), leading: const Icon(Icons.delete_outline),
title: const Text('Nachricht löschen'), title: const Text('Nachricht löschen'),
onTap: () async { onPressed: () async {
await DeleteMessage(chatData.token, bubbleData.id).run(); await DeleteMessage(chatData.token, bubbleData.id).run();
if (!dialogCtx.mounted) return; if (dialogCtx.mounted) dialogCtx.read<ChatBloc>().refresh();
dialogCtx.read<ChatBloc>().refresh();
Navigator.of(dialogCtx).pop();
}, },
), ),
DebugTile(dialogCtx).jsonData(bubbleData.toJson()), DebugTile(dialogCtx).jsonData(bubbleData.toJson()),
@@ -94,7 +94,7 @@ Future<void> showChatMessageOptionsDialog(
); );
} }
class _ReactionsRow extends StatelessWidget { class _ReactionsRow extends StatefulWidget {
final String chatToken; final String chatToken;
final int messageId; final int messageId;
final void Function({bool renew}) onRefetch; final void Function({bool renew}) onRefetch;
@@ -107,17 +107,41 @@ class _ReactionsRow extends StatelessWidget {
required this.dialogContext, required this.dialogContext,
}); });
void _react(String emoji) { @override
Navigator.of(dialogContext).pop(); State<_ReactionsRow> createState() => _ReactionsRowState();
ReactMessage( }
chatToken: chatToken,
messageId: messageId, 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), params: ReactMessageParams(emoji),
).run().then((_) => onRefetch(renew: true)); ).run();
});
if (!mounted) return;
if (ok) {
widget.onRefetch(renew: true);
if (widget.dialogContext.mounted) Navigator.of(widget.dialogContext).pop();
}
} }
@override @override
Widget build(BuildContext context) => Column( Widget build(BuildContext context) => AnimatedBuilder(
animation: _controller,
builder: (context, _) {
final busy = _controller.busy;
final err = _controller.error;
return Column(
mainAxisSize: MainAxisSize.min, mainAxisSize: MainAxisSize.min,
children: [ children: [
Wrap( Wrap(
@@ -130,24 +154,37 @@ class _ReactionsRow extends StatelessWidget {
tapTargetSize: MaterialTapTargetSize.shrinkWrap, tapTargetSize: MaterialTapTargetSize.shrinkWrap,
minimumSize: const Size(40, 40), minimumSize: const Size(40, 40),
), ),
onPressed: () => _react(emoji), onPressed: busy ? null : () => _react(emoji),
child: Text(emoji), child: Text(emoji),
), ),
), ),
IconButton( IconButton(
onPressed: () => _showEmojiPicker(context), onPressed: busy ? null : () => _showEmojiPicker(context),
style: IconButton.styleFrom( style: IconButton.styleFrom(
padding: EdgeInsets.zero, padding: EdgeInsets.zero,
tapTargetSize: MaterialTapTargetSize.shrinkWrap, tapTargetSize: MaterialTapTargetSize.shrinkWrap,
minimumSize: const Size(40, 40), minimumSize: const Size(40, 40),
), ),
icon: const Icon(Icons.add_circle_outline_outlined), icon: busy
? const AppProgressIndicator.small()
: const Icon(Icons.add_circle_outline_outlined),
), ),
], ],
), ),
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),
),
),
const Divider(), const Divider(),
], ],
); );
},
);
void _showEmojiPicker(BuildContext rowContext) { void _showEmojiPicker(BuildContext rowContext) {
showDialog( showDialog(
+45 -29
View File
@@ -12,6 +12,7 @@ import '../../../../api/marianumcloud/talk/sendMessage/sendMessageParams.dart';
import '../../../../api/marianumcloud/webdav/webdavApi.dart'; import '../../../../api/marianumcloud/webdav/webdavApi.dart';
import '../../../../state/app/modules/chat/bloc/chat_bloc.dart'; import '../../../../state/app/modules/chat/bloc/chat_bloc.dart';
import '../../../../state/app/modules/settings/bloc/settings_cubit.dart'; import '../../../../state/app/modules/settings/bloc/settings_cubit.dart';
import '../../../../widget/async_action_button.dart';
import '../../../../widget/file_pick.dart'; import '../../../../widget/file_pick.dart';
import '../../../../widget/focus_behaviour.dart'; import '../../../../widget/focus_behaviour.dart';
import '../../files/files_upload_dialog.dart'; import '../../files/files_upload_dialog.dart';
@@ -30,7 +31,8 @@ class ChatTextfield extends StatefulWidget {
class _ChatTextfieldState extends State<ChatTextfield> { class _ChatTextfieldState extends State<ChatTextfield> {
late SettingsCubit settings; late SettingsCubit settings;
final TextEditingController _textBoxController = TextEditingController(); final TextEditingController _textBoxController = TextEditingController();
bool isLoading = false; final AsyncActionController _sendController = AsyncActionController();
String? _sendError;
void share(String shareFolder, List<String> filePaths) { void share(String shareFolder, List<String> filePaths) {
for (final element in 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 @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
_textBoxController.text = settings.val().talkSettings.drafts[widget.sendToToken] ?? ''; _textBoxController.text = settings.val().talkSettings.drafts[widget.sendToToken] ?? '';
@@ -135,6 +160,14 @@ class _ChatTextfieldState extends State<ChatTextfield> {
child: Column( child: Column(
children: [ children: [
replyBanner, 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>[ Row(children: <Widget>[
GestureDetector( GestureDetector(
onTap: () { onTap: () {
@@ -200,36 +233,19 @@ class _ChatTextfieldState extends State<ChatTextfield> {
), ),
), ),
const SizedBox(width: 15), const SizedBox(width: 15),
FloatingActionButton( ValueListenableBuilder<TextEditingValue>(
valueListenable: _textBoxController,
builder: (context, value, _) => AsyncFab(
mini: true, mini: true,
onPressed: () { heroTag: 'chatSend_${widget.sendToToken}',
if (_textBoxController.text.isEmpty || isLoading) return; icon: Icons.send,
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, backgroundColor: Theme.of(context).primaryColor,
elevation: 5, foregroundColor: Colors.white,
child: isLoading controller: _sendController,
? Container( onPressed: value.text.trim().isEmpty ? null : () => _sendMessage(chatBloc),
padding: const EdgeInsets.all(10), onError: (message) => setState(() => _sendError = message),
child: const CircularProgressIndicator(color: Colors.white, strokeWidth: 2), onSuccess: () => setState(() => _sendError = null),
) ),
: const Icon(Icons.send, color: Colors.white, size: 18),
), ),
]), ]),
], ],
+34 -45
View File
@@ -1,3 +1,4 @@
import 'dart:async';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.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 '../../../../model/account_data.dart';
import '../../../../state/app/modules/chat/bloc/chat_bloc.dart'; import '../../../../state/app/modules/chat/bloc/chat_bloc.dart';
import '../../../../state/app/modules/chatList/bloc/chat_list_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/confirm_dialog.dart';
import '../../../../widget/debug/debug_tile.dart'; import '../../../../widget/debug/debug_tile.dart';
import '../../../../widget/user_avatar.dart'; import '../../../../widget/user_avatar.dart';
@@ -42,15 +44,14 @@ class _ChatTileState extends State<ChatTile> {
void _refreshList() => context.read<ChatListBloc>().refresh(); void _refreshList() => context.read<ChatListBloc>().refresh();
void setCurrentAsRead() { Future<void> _setCurrentAsRead() async {
SetReadMarker( await SetReadMarker(
widget.data.token, widget.data.token,
true, true,
setReadMarkerParams: SetReadMarkerParams(lastReadMessage: widget.data.lastMessage.id), setReadMarkerParams: SetReadMarkerParams(lastReadMessage: widget.data.lastMessage.id),
).run().then((_) { ).run();
if (!mounted) return; if (!mounted) return;
_refreshList(); _refreshList();
});
} }
@override @override
@@ -116,7 +117,7 @@ class _ChatTileState extends State<ChatTile> {
), ),
onTap: () { onTap: () {
if (selfUsername == null) return; if (selfUsername == null) return;
setCurrentAsRead(); unawaited(_setCurrentAsRead());
final view = ChatView(room: widget.data, selfId: selfUsername!, avatar: circleAvatar); final view = ChatView(room: widget.data, selfId: selfUsername!, avatar: circleAvatar);
TalkNavigator.pushSplitView(context, view, overrideToSingleSubScreen: true); TalkNavigator.pushSplitView(context, view, overrideToSingleSubScreen: true);
context.read<ChatBloc>().setToken(widget.data.token); context.read<ChatBloc>().setToken(widget.data.token);
@@ -125,65 +126,53 @@ class _ChatTileState extends State<ChatTile> {
if (widget.disableContextActions) return; if (widget.disableContextActions) return;
showDialog(context: context, builder: (dialogCtx) => SimpleDialog( showDialog(context: context, builder: (dialogCtx) => SimpleDialog(
children: [ children: [
Visibility( if (widget.data.unreadMessages > 0)
visible: widget.data.unreadMessages > 0, AsyncListTile(
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(
leading: const Icon(Icons.mark_chat_read_outlined), leading: const Icon(Icons.mark_chat_read_outlined),
title: const Text('Als gelesen markieren'), title: const Text('Als gelesen markieren'),
onTap: () { onPressed: _setCurrentAsRead,
setCurrentAsRead(); )
Navigator.of(dialogCtx).pop(); else
}, AsyncListTile(
), leading: const Icon(Icons.mark_chat_unread_outlined),
), title: const Text('Als ungelesen markieren'),
Visibility( onPressed: () async {
visible: widget.data.isFavorite, await SetReadMarker(widget.data.token, false).run();
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(); if (mounted) _refreshList();
});
Navigator.of(dialogCtx).pop();
}, },
), ),
child: ListTile( if (widget.data.isFavorite)
AsyncListTile(
leading: const Icon(Icons.stars_outlined), leading: const Icon(Icons.stars_outlined),
title: const Text('Von Favoriten entfernen'), title: const Text('Von Favoriten entfernen'),
onTap: () { onPressed: () async {
SetFavorite(widget.data.token, false).run().then((_) { 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(); if (mounted) _refreshList();
});
Navigator.of(dialogCtx).pop();
}, },
),
), ),
ListTile( ListTile(
leading: const Icon(Icons.delete_outline), leading: const Icon(Icons.delete_outline),
title: const Text('Konversation verlassen'), title: const Text('Konversation verlassen'),
onTap: () { onTap: () {
Navigator.of(dialogCtx).pop();
ConfirmDialog( ConfirmDialog(
title: 'Chat verlassen', title: 'Chat verlassen',
content: 'Du benötigst ggf. eine Einladung um erneut beizutreten.', content: 'Du benötigst ggf. eine Einladung um erneut beizutreten.',
confirmButton: 'Löschen', confirmButton: 'Verlassen',
onConfirm: () { onConfirmAsync: () async {
LeaveRoom(widget.data.token).run().then((_) { await LeaveRoom(widget.data.token).run();
if (mounted) _refreshList(); if (mounted) _refreshList();
});
Navigator.of(dialogCtx).pop();
}, },
).asDialog(dialogCtx); ).asDialog(context);
}, },
), ),
DebugTile(dialogCtx).jsonData(widget.data.toJson()), 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.', content: 'Der ${event.rrule.isEmpty ? "Termin" : "Serientermin"} wird unwiederruflich gelöscht.',
confirmButton: 'Löschen', confirmButton: 'Löschen',
onConfirm: () { onConfirm: () {
bloc.removeCustomEvent(event.id).then(completer.complete).onError((Object error, StackTrace stack) { bloc.removeCustomEvent(event.id).then(completer.complete).onError(completer.completeError);
completer.completeError(error, stack);
});
}, },
).asDialog(context); ).asDialog(context);
return completer; 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_bloc.dart';
import '../../../state/app/modules/timetable/bloc/timetable_state.dart'; import '../../../state/app/modules/timetable/bloc/timetable_state.dart';
import '../../../state/app/modules/settings/bloc/settings_cubit.dart'; import '../../../state/app/modules/settings/bloc/settings_cubit.dart';
import '../../../storage/timetable_settings.dart';
import 'custom_events/custom_event_edit_dialog.dart'; import 'custom_events/custom_event_edit_dialog.dart';
import 'data/arbitrary_appointment.dart'; import 'data/arbitrary_appointment.dart';
import 'data/lesson_period_schedule.dart'; import 'data/lesson_period_schedule.dart';
@@ -30,6 +31,7 @@ class _TimetableState extends State<Timetable> {
List<Appointment>? _cachedAppointments; List<Appointment>? _cachedAppointments;
int? _lastDataVersion; int? _lastDataVersion;
TimetableSettings? _lastTimetableSettings;
DateTime _initialDisplayDate() => DateTime.now().add(const Duration(days: 2)); DateTime _initialDisplayDate() => DateTime.now().add(const Duration(days: 2));
@@ -51,18 +53,21 @@ class _TimetableState extends State<Timetable> {
} }
List<Appointment> _appointments(TimetableState state) { 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!; return _cachedAppointments!;
} }
_lastDataVersion = state.dataVersion; _lastDataVersion = state.dataVersion;
_lastTimetableSettings = timetableSettings;
final settings = context.read<SettingsCubit>();
return _cachedAppointments = TimetableAppointmentFactory( return _cachedAppointments = TimetableAppointmentFactory(
lessons: state.getAllKnownLessons().toList(), lessons: state.getAllKnownLessons().toList(),
customEvents: state.customEvents?.events ?? const [], customEvents: state.customEvents?.events ?? const [],
rooms: state.rooms!, rooms: state.rooms!,
subjects: state.subjects!, subjects: state.subjects!,
settings: settings.val().timetableSettings, settings: timetableSettings,
now: DateTime.now(), now: DateTime.now(),
).build(); ).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),
),
],
),
],
);
},
);
}
+38 -9
View File
@@ -1,14 +1,30 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:url_launcher/url_launcher.dart'; import 'package:url_launcher/url_launcher.dart';
import 'async_action_button.dart';
class ConfirmDialog extends StatelessWidget { class ConfirmDialog extends StatelessWidget {
final String title; final String title;
final String content; final String content;
final IconData? icon; final IconData? icon;
final String confirmButton; final String confirmButton;
final String cancelButton; final String cancelButton;
final void Function() onConfirm; 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 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) { void asDialog(BuildContext context) {
showDialog(context: context, builder: build); showDialog(context: context, builder: build);
@@ -19,14 +35,27 @@ class ConfirmDialog extends StatelessWidget {
icon: icon != null ? Icon(icon) : null, icon: icon != null ? Icon(icon) : null,
title: Text(title), title: Text(title),
content: Text(content), content: Text(content),
actions: [ actions: onConfirmAsync != null
TextButton(onPressed: () { ? [
AsyncDialogAction(
confirmLabel: confirmButton,
cancelLabel: cancelButton,
onConfirm: onConfirmAsync!,
errorBuilder: errorBuilder,
),
]
: [
TextButton(
onPressed: () => Navigator.of(context).pop(),
child: Text(cancelButton),
),
TextButton(
onPressed: () {
Navigator.of(context).pop(); Navigator.of(context).pop();
}, child: Text(cancelButton)), onConfirm!();
TextButton(onPressed: () { },
Navigator.of(context).pop(); child: Text(confirmButton),
onConfirm(); ),
}, child: Text(confirmButton)),
], ],
); );