loading state and error handling refactor
This commit is contained in:
@@ -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)';
|
||||
}
|
||||
@@ -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,
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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,
|
||||
);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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,
|
||||
);
|
||||
}
|
||||
@@ -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}).';
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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}).';
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,11 +1,17 @@
|
||||
import 'dart:async';
|
||||
import 'dart:developer';
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:http/http.dart' as http;
|
||||
|
||||
import '../../apiError.dart';
|
||||
import '../../apiParams.dart';
|
||||
import '../../apiRequest.dart';
|
||||
import '../../apiResponse.dart';
|
||||
import '../../errors/auth_exception.dart';
|
||||
import '../../errors/network_exception.dart';
|
||||
import '../../errors/not_found_exception.dart';
|
||||
import '../../errors/parse_exception.dart';
|
||||
import '../../errors/server_exception.dart';
|
||||
import '../nextcloud_ocs.dart';
|
||||
|
||||
enum TalkApiMethod {
|
||||
@@ -32,16 +38,32 @@ abstract class TalkApi<T extends ApiResponse?> extends ApiRequest {
|
||||
final endpoint = NextcloudOcs.uri('apps/spreed/api/$path', queryParameters: getParameters);
|
||||
final mergedHeaders = {...NextcloudOcs.headers(), ...?headers};
|
||||
|
||||
http.Response? data;
|
||||
final http.Response data;
|
||||
try {
|
||||
data = await request(endpoint, body, mergedHeaders);
|
||||
if (data == null) throw Exception('No response Data');
|
||||
if (data.statusCode >= 400 || data.statusCode < 200) {
|
||||
throw Exception("Response status code '${data.statusCode}' might indicate an error");
|
||||
final raw = await request(endpoint, body, mergedHeaders);
|
||||
if (raw == null) {
|
||||
throw const NetworkException(
|
||||
userMessage: 'Keine Antwort vom Talk-Server erhalten.',
|
||||
technicalDetails: 'Talk request returned null',
|
||||
);
|
||||
}
|
||||
} catch (e) {
|
||||
log(e.toString());
|
||||
throw ApiError('Request $endpoint could not be dispatched: ${e.toString()}');
|
||||
data = raw;
|
||||
} on SocketException catch (e) {
|
||||
throw NetworkException(technicalDetails: 'Talk $endpoint: ${e.message}');
|
||||
} on TimeoutException catch (e) {
|
||||
throw NetworkException.timeout(technicalDetails: 'Talk $endpoint: $e');
|
||||
} on http.ClientException catch (e) {
|
||||
throw NetworkException(technicalDetails: 'Talk $endpoint: ${e.message}');
|
||||
}
|
||||
|
||||
final status = data.statusCode;
|
||||
if (status < 200 || status >= 300) {
|
||||
final detail = 'Talk $endpoint -> HTTP $status';
|
||||
log(detail);
|
||||
if (status == 401) throw AuthException.unauthorized(technicalDetails: detail);
|
||||
if (status == 403) throw AuthException.forbidden(technicalDetails: detail);
|
||||
if (status == 404) throw NotFoundException(technicalDetails: detail);
|
||||
throw ServerException(statusCode: status, technicalDetails: detail);
|
||||
}
|
||||
|
||||
try {
|
||||
@@ -49,10 +71,7 @@ abstract class TalkApi<T extends ApiResponse?> extends ApiRequest {
|
||||
assembled?.headers = data.headers;
|
||||
return assembled;
|
||||
} catch (e) {
|
||||
final message = 'Error assembling Talk API ${T.toString()} message: ${e.toString()}'
|
||||
' response with request body: $body and request headers: $mergedHeaders';
|
||||
log(message);
|
||||
throw Exception(message);
|
||||
throw ParseException(technicalDetails: 'Talk $endpoint assemble: $e');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,9 +1,14 @@
|
||||
import 'dart:async';
|
||||
import 'dart:convert';
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:http/http.dart' as http;
|
||||
import 'package:jiffy/jiffy.dart';
|
||||
import '../apiError.dart';
|
||||
|
||||
import '../apiRequest.dart';
|
||||
import '../errors/network_exception.dart';
|
||||
import '../errors/parse_exception.dart';
|
||||
import '../errors/server_exception.dart';
|
||||
|
||||
abstract class MhslApi<T> extends ApiRequest {
|
||||
String subpath;
|
||||
@@ -15,18 +20,38 @@ abstract class MhslApi<T> extends ApiRequest {
|
||||
T assemble(String raw);
|
||||
|
||||
Future<T> run() async {
|
||||
var endpoint = Uri.parse('https://mhsl.eu/marianum/marianummobile/$subpath');
|
||||
final endpoint = Uri.parse('https://mhsl.eu/marianum/marianummobile/$subpath');
|
||||
|
||||
var data = await request(endpoint);
|
||||
if(data == null) {
|
||||
throw ApiError('Request could not be dispatched!');
|
||||
final http.Response data;
|
||||
try {
|
||||
final raw = await request(endpoint);
|
||||
if (raw == null) {
|
||||
throw const NetworkException(
|
||||
userMessage: 'Keine Antwort vom MHSL-Dienst erhalten.',
|
||||
technicalDetails: 'mhsl request returned null',
|
||||
);
|
||||
}
|
||||
data = raw;
|
||||
} on SocketException catch (e) {
|
||||
throw NetworkException(technicalDetails: 'mhsl $subpath: ${e.message}');
|
||||
} on TimeoutException catch (e) {
|
||||
throw NetworkException.timeout(technicalDetails: 'mhsl $subpath: $e');
|
||||
} on http.ClientException catch (e) {
|
||||
throw NetworkException(technicalDetails: 'mhsl $subpath: ${e.message}');
|
||||
}
|
||||
|
||||
if (data.statusCode > 299) {
|
||||
throw ApiError('Non 200 Status code from mhsl services: $subpath: ${data.statusCode}');
|
||||
throw ServerException(
|
||||
statusCode: data.statusCode,
|
||||
technicalDetails: 'mhsl $subpath HTTP ${data.statusCode}',
|
||||
);
|
||||
}
|
||||
|
||||
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');
|
||||
|
||||
@@ -1,10 +1,15 @@
|
||||
import 'dart:async';
|
||||
import 'dart:convert';
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:http/http.dart' as http;
|
||||
|
||||
import '../../model/endpoint_data.dart';
|
||||
import '../apiParams.dart';
|
||||
import '../apiRequest.dart';
|
||||
import '../apiResponse.dart';
|
||||
import '../errors/network_exception.dart';
|
||||
import '../errors/parse_exception.dart';
|
||||
import 'queries/authenticate/authenticate.dart';
|
||||
import 'webuntisError.dart';
|
||||
|
||||
@@ -29,10 +34,15 @@ abstract class WebuntisApi extends ApiRequest {
|
||||
var data = await post(query, {'Cookie': 'JSESSIONID=$sessionId'});
|
||||
response = data;
|
||||
|
||||
dynamic jsonData = jsonDecode(data.body);
|
||||
final dynamic jsonData;
|
||||
try {
|
||||
jsonData = jsonDecode(data.body);
|
||||
} on FormatException catch (e) {
|
||||
throw ParseException(technicalDetails: 'WebUntis JSON decode: ${e.message}');
|
||||
}
|
||||
if(jsonData['error'] != null) {
|
||||
if(jsonData['error']['code'] == -8520) {
|
||||
if(retry) throw WebuntisError('Authentication was tried (probably session timeout), but was not successful!', 1);
|
||||
if(retry) throw WebuntisError('Authentication was tried (probably session timeout), but was not successful!', -8520);
|
||||
await Authenticate.createSession();
|
||||
return await this.query(untis, retry: true);
|
||||
} else {
|
||||
@@ -51,10 +61,16 @@ abstract class WebuntisApi extends ApiRequest {
|
||||
|
||||
String _body() => genericParam == null ? '{}' : jsonEncode(genericParam);
|
||||
|
||||
Future<http.Response> post(String data, Map<String, String>? headers) async => await http
|
||||
.post(endpoint, body: data, headers: headers)
|
||||
.timeout(
|
||||
Future<http.Response> post(String data, Map<String, String>? headers) async {
|
||||
try {
|
||||
return await http.post(endpoint, body: data, headers: headers).timeout(
|
||||
const Duration(seconds: 10),
|
||||
onTimeout: () => throw 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
@@ -32,7 +32,7 @@ import 'storage/settings.dart';
|
||||
import 'theming/dark_app_theme.dart';
|
||||
import 'theming/light_app_theme.dart';
|
||||
import 'view/login/login.dart';
|
||||
import 'widget/placeholder_view.dart';
|
||||
import 'widget/app_progress_indicator.dart';
|
||||
|
||||
Future<void> main() async {
|
||||
log('MarianumMobile started');
|
||||
@@ -68,9 +68,21 @@ Future<void> main() async {
|
||||
);
|
||||
|
||||
if (kReleaseMode) {
|
||||
ErrorWidget.builder = (error) => PlaceholderView(
|
||||
icon: Icons.phonelink_erase_rounded,
|
||||
text: error.toStringShort(),
|
||||
ErrorWidget.builder = (error) => Material(
|
||||
color: Colors.white,
|
||||
child: Center(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(24),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
const Icon(Icons.phonelink_erase_rounded, size: 40),
|
||||
const SizedBox(height: 12),
|
||||
Text(error.toStringShort(), textAlign: TextAlign.center),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -156,7 +168,18 @@ class _MainState extends State<Main> {
|
||||
case AccountStatus.loggedOut:
|
||||
return const Login();
|
||||
case AccountStatus.undefined:
|
||||
return const PlaceholderView(icon: Icons.timer, text: 'Daten werden geladen');
|
||||
return const Scaffold(
|
||||
body: Center(
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
AppProgressIndicator.large(),
|
||||
SizedBox(height: 16),
|
||||
Text('Konto wird geladen…'),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
},
|
||||
),
|
||||
|
||||
@@ -41,6 +41,12 @@ class LoadableStateBloc extends Bloc<LoadableStateEvent, LoadableStateState> {
|
||||
? Colors.grey.shade600
|
||||
: Theme.of(context).primaryColor;
|
||||
|
||||
Color connectionForegroundColor(BuildContext context) => connectivityStatusKnown() && !isConnected()
|
||||
? Colors.white
|
||||
: ThemeData.estimateBrightnessForColor(Theme.of(context).primaryColor) == Brightness.dark
|
||||
? Colors.white
|
||||
: Colors.black;
|
||||
|
||||
String connectionText({int? lastUpdated}) => connectivityStatusKnown()
|
||||
? isConnected()
|
||||
? 'Verbindung fehlgeschlagen'
|
||||
|
||||
@@ -6,6 +6,7 @@ part 'loading_error.freezed.dart';
|
||||
abstract class LoadingError with _$LoadingError {
|
||||
const factory LoadingError({
|
||||
required String message,
|
||||
String? technicalDetails,
|
||||
@Default(false) bool allowRetry,
|
||||
}) = _LoadingError;
|
||||
}
|
||||
|
||||
@@ -14,7 +14,7 @@ T _$identity<T>(T value) => value;
|
||||
/// @nodoc
|
||||
mixin _$LoadingError {
|
||||
|
||||
String get message; bool get allowRetry;
|
||||
String get message; String? get technicalDetails; bool get allowRetry;
|
||||
/// Create a copy of LoadingError
|
||||
/// with the given fields replaced by the non-null parameter values.
|
||||
@JsonKey(includeFromJson: false, includeToJson: false)
|
||||
@@ -25,16 +25,16 @@ $LoadingErrorCopyWith<LoadingError> get copyWith => _$LoadingErrorCopyWithImpl<L
|
||||
|
||||
@override
|
||||
bool operator ==(Object other) {
|
||||
return identical(this, other) || (other.runtimeType == runtimeType&&other is LoadingError&&(identical(other.message, message) || other.message == message)&&(identical(other.allowRetry, allowRetry) || other.allowRetry == allowRetry));
|
||||
return identical(this, other) || (other.runtimeType == runtimeType&&other is LoadingError&&(identical(other.message, message) || other.message == message)&&(identical(other.technicalDetails, technicalDetails) || other.technicalDetails == technicalDetails)&&(identical(other.allowRetry, allowRetry) || other.allowRetry == allowRetry));
|
||||
}
|
||||
|
||||
|
||||
@override
|
||||
int get hashCode => Object.hash(runtimeType,message,allowRetry);
|
||||
int get hashCode => Object.hash(runtimeType,message,technicalDetails,allowRetry);
|
||||
|
||||
@override
|
||||
String toString() {
|
||||
return 'LoadingError(message: $message, allowRetry: $allowRetry)';
|
||||
return 'LoadingError(message: $message, technicalDetails: $technicalDetails, allowRetry: $allowRetry)';
|
||||
}
|
||||
|
||||
|
||||
@@ -45,7 +45,7 @@ abstract mixin class $LoadingErrorCopyWith<$Res> {
|
||||
factory $LoadingErrorCopyWith(LoadingError value, $Res Function(LoadingError) _then) = _$LoadingErrorCopyWithImpl;
|
||||
@useResult
|
||||
$Res call({
|
||||
String message, bool allowRetry
|
||||
String message, String? technicalDetails, bool allowRetry
|
||||
});
|
||||
|
||||
|
||||
@@ -62,10 +62,11 @@ class _$LoadingErrorCopyWithImpl<$Res>
|
||||
|
||||
/// Create a copy of LoadingError
|
||||
/// with the given fields replaced by the non-null parameter values.
|
||||
@pragma('vm:prefer-inline') @override $Res call({Object? message = null,Object? allowRetry = null,}) {
|
||||
@pragma('vm:prefer-inline') @override $Res call({Object? message = null,Object? technicalDetails = freezed,Object? allowRetry = null,}) {
|
||||
return _then(_self.copyWith(
|
||||
message: null == message ? _self.message : message // ignore: cast_nullable_to_non_nullable
|
||||
as String,allowRetry: null == allowRetry ? _self.allowRetry : allowRetry // ignore: cast_nullable_to_non_nullable
|
||||
as String,technicalDetails: freezed == technicalDetails ? _self.technicalDetails : technicalDetails // ignore: cast_nullable_to_non_nullable
|
||||
as String?,allowRetry: null == allowRetry ? _self.allowRetry : allowRetry // ignore: cast_nullable_to_non_nullable
|
||||
as bool,
|
||||
));
|
||||
}
|
||||
@@ -151,10 +152,10 @@ return $default(_that);case _:
|
||||
/// }
|
||||
/// ```
|
||||
|
||||
@optionalTypeArgs TResult maybeWhen<TResult extends Object?>(TResult Function( String message, bool allowRetry)? $default,{required TResult orElse(),}) {final _that = this;
|
||||
@optionalTypeArgs TResult maybeWhen<TResult extends Object?>(TResult Function( String message, String? technicalDetails, bool allowRetry)? $default,{required TResult orElse(),}) {final _that = this;
|
||||
switch (_that) {
|
||||
case _LoadingError() when $default != null:
|
||||
return $default(_that.message,_that.allowRetry);case _:
|
||||
return $default(_that.message,_that.technicalDetails,_that.allowRetry);case _:
|
||||
return orElse();
|
||||
|
||||
}
|
||||
@@ -172,10 +173,10 @@ return $default(_that.message,_that.allowRetry);case _:
|
||||
/// }
|
||||
/// ```
|
||||
|
||||
@optionalTypeArgs TResult when<TResult extends Object?>(TResult Function( String message, bool allowRetry) $default,) {final _that = this;
|
||||
@optionalTypeArgs TResult when<TResult extends Object?>(TResult Function( String message, String? technicalDetails, bool allowRetry) $default,) {final _that = this;
|
||||
switch (_that) {
|
||||
case _LoadingError():
|
||||
return $default(_that.message,_that.allowRetry);case _:
|
||||
return $default(_that.message,_that.technicalDetails,_that.allowRetry);case _:
|
||||
throw StateError('Unexpected subclass');
|
||||
|
||||
}
|
||||
@@ -192,10 +193,10 @@ return $default(_that.message,_that.allowRetry);case _:
|
||||
/// }
|
||||
/// ```
|
||||
|
||||
@optionalTypeArgs TResult? whenOrNull<TResult extends Object?>(TResult? Function( String message, bool allowRetry)? $default,) {final _that = this;
|
||||
@optionalTypeArgs TResult? whenOrNull<TResult extends Object?>(TResult? Function( String message, String? technicalDetails, bool allowRetry)? $default,) {final _that = this;
|
||||
switch (_that) {
|
||||
case _LoadingError() when $default != null:
|
||||
return $default(_that.message,_that.allowRetry);case _:
|
||||
return $default(_that.message,_that.technicalDetails,_that.allowRetry);case _:
|
||||
return null;
|
||||
|
||||
}
|
||||
@@ -207,10 +208,11 @@ return $default(_that.message,_that.allowRetry);case _:
|
||||
|
||||
|
||||
class _LoadingError implements LoadingError {
|
||||
const _LoadingError({required this.message, this.allowRetry = false});
|
||||
const _LoadingError({required this.message, this.technicalDetails, this.allowRetry = false});
|
||||
|
||||
|
||||
@override final String message;
|
||||
@override final String? technicalDetails;
|
||||
@override@JsonKey() final bool allowRetry;
|
||||
|
||||
/// Create a copy of LoadingError
|
||||
@@ -223,16 +225,16 @@ _$LoadingErrorCopyWith<_LoadingError> get copyWith => __$LoadingErrorCopyWithImp
|
||||
|
||||
@override
|
||||
bool operator ==(Object other) {
|
||||
return identical(this, other) || (other.runtimeType == runtimeType&&other is _LoadingError&&(identical(other.message, message) || other.message == message)&&(identical(other.allowRetry, allowRetry) || other.allowRetry == allowRetry));
|
||||
return identical(this, other) || (other.runtimeType == runtimeType&&other is _LoadingError&&(identical(other.message, message) || other.message == message)&&(identical(other.technicalDetails, technicalDetails) || other.technicalDetails == technicalDetails)&&(identical(other.allowRetry, allowRetry) || other.allowRetry == allowRetry));
|
||||
}
|
||||
|
||||
|
||||
@override
|
||||
int get hashCode => Object.hash(runtimeType,message,allowRetry);
|
||||
int get hashCode => Object.hash(runtimeType,message,technicalDetails,allowRetry);
|
||||
|
||||
@override
|
||||
String toString() {
|
||||
return 'LoadingError(message: $message, allowRetry: $allowRetry)';
|
||||
return 'LoadingError(message: $message, technicalDetails: $technicalDetails, allowRetry: $allowRetry)';
|
||||
}
|
||||
|
||||
|
||||
@@ -243,7 +245,7 @@ abstract mixin class _$LoadingErrorCopyWith<$Res> implements $LoadingErrorCopyWi
|
||||
factory _$LoadingErrorCopyWith(_LoadingError value, $Res Function(_LoadingError) _then) = __$LoadingErrorCopyWithImpl;
|
||||
@override @useResult
|
||||
$Res call({
|
||||
String message, bool allowRetry
|
||||
String message, String? technicalDetails, bool allowRetry
|
||||
});
|
||||
|
||||
|
||||
@@ -260,10 +262,11 @@ class __$LoadingErrorCopyWithImpl<$Res>
|
||||
|
||||
/// Create a copy of LoadingError
|
||||
/// with the given fields replaced by the non-null parameter values.
|
||||
@override @pragma('vm:prefer-inline') $Res call({Object? message = null,Object? allowRetry = null,}) {
|
||||
@override @pragma('vm:prefer-inline') $Res call({Object? message = null,Object? technicalDetails = freezed,Object? allowRetry = null,}) {
|
||||
return _then(_LoadingError(
|
||||
message: null == message ? _self.message : message // ignore: cast_nullable_to_non_nullable
|
||||
as String,allowRetry: null == allowRetry ? _self.allowRetry : allowRetry // ignore: cast_nullable_to_non_nullable
|
||||
as String,technicalDetails: freezed == technicalDetails ? _self.technicalDetails : technicalDetails // ignore: cast_nullable_to_non_nullable
|
||||
as String?,allowRetry: null == allowRetry ? _self.allowRetry : allowRetry // ignore: cast_nullable_to_non_nullable
|
||||
as bool,
|
||||
));
|
||||
}
|
||||
|
||||
@@ -88,6 +88,7 @@ class LoadableStateConsumer<TController extends Bloc<LoadableHydratedBlocEvent<T
|
||||
visible: showErrorBar,
|
||||
hasContent: hasContent,
|
||||
message: loadableState.error?.message,
|
||||
technicalDetails: loadableState.error?.technicalDetails,
|
||||
lastUpdated: loadableState.lastFetch,
|
||||
),
|
||||
Expanded(
|
||||
@@ -95,7 +96,11 @@ class LoadableStateConsumer<TController extends Bloc<LoadableHydratedBlocEvent<T
|
||||
children: [
|
||||
LoadableStatePrimaryLoading(visible: showPrimaryLoading),
|
||||
LoadableStateBackgroundLoading(visible: showBackgroundLoading),
|
||||
LoadableStateErrorScreen(visible: showError, message: loadableState.error?.message),
|
||||
LoadableStateErrorScreen(
|
||||
visible: showError,
|
||||
message: loadableState.error?.message,
|
||||
technicalDetails: loadableState.error?.technicalDetails,
|
||||
),
|
||||
|
||||
AnimatedOpacity(
|
||||
opacity: hasContent ? 1.0 : 0.0,
|
||||
|
||||
@@ -10,11 +10,13 @@ class LoadableStateErrorBar extends StatelessWidget {
|
||||
final bool visible;
|
||||
final bool hasContent;
|
||||
final String? message;
|
||||
final String? technicalDetails;
|
||||
final int? lastUpdated;
|
||||
const LoadableStateErrorBar({
|
||||
required this.visible,
|
||||
this.hasContent = false,
|
||||
this.message,
|
||||
this.technicalDetails,
|
||||
this.lastUpdated,
|
||||
super.key,
|
||||
});
|
||||
@@ -48,7 +50,12 @@ class LoadableStateErrorBar extends StatelessWidget {
|
||||
return InkWell(
|
||||
onTap: () {
|
||||
if(!bloc.isConnected()) return;
|
||||
InfoDialog.show(context, 'Exception: ${message.toString()}');
|
||||
final body = [
|
||||
if (message != null && message!.isNotEmpty) message!,
|
||||
if (technicalDetails != null && technicalDetails!.isNotEmpty) technicalDetails!,
|
||||
].join('\n\n');
|
||||
if (body.isEmpty) return;
|
||||
InfoDialog.show(context, body);
|
||||
},
|
||||
child: Container(
|
||||
height: 20,
|
||||
@@ -85,13 +92,17 @@ class _LoadableStateErrorBarTextState extends State<LoadableStateErrorBarText> {
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
var bloc = context.watch<LoadableStateBloc>();
|
||||
final foreground = bloc.connectionForegroundColor(context);
|
||||
|
||||
return Row(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Icon(bloc.connectionIcon(), size: 14),
|
||||
Icon(bloc.connectionIcon(), size: 14, color: foreground),
|
||||
const SizedBox(width: 10),
|
||||
Text(bloc.connectionText(lastUpdated: widget.lastUpdated), style: const TextStyle(fontSize: 12))
|
||||
Text(
|
||||
bloc.connectionText(lastUpdated: widget.lastUpdated),
|
||||
style: TextStyle(fontSize: 12, color: foreground),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,51 +1,71 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
|
||||
import '../../../../../widget/info_dialog.dart';
|
||||
import '../bloc/loadable_state_bloc.dart';
|
||||
import 'loadable_state_consumer.dart';
|
||||
|
||||
class LoadableStateErrorScreen extends StatelessWidget {
|
||||
final bool visible;
|
||||
final String? message;
|
||||
const LoadableStateErrorScreen({required this.visible, this.message, super.key});
|
||||
|
||||
final String? technicalDetails;
|
||||
const LoadableStateErrorScreen({
|
||||
required this.visible,
|
||||
this.message,
|
||||
this.technicalDetails,
|
||||
super.key,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final bloc = context.watch<LoadableStateBloc>();
|
||||
final isOffline = bloc.connectivityStatusKnown() && !bloc.isConnected();
|
||||
final headline = isOffline ? bloc.connectionText() : (message ?? bloc.connectionText());
|
||||
|
||||
return AnimatedOpacity(
|
||||
opacity: visible ? 1.0 : 0.0,
|
||||
duration: LoadableStateConsumer.animationDuration,
|
||||
curve: Curves.easeInOut,
|
||||
child: !visible ? null : Center(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 24),
|
||||
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)),
|
||||
|
||||
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: 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,
|
||||
const SizedBox(height: 16),
|
||||
TextButton(
|
||||
onPressed: () => bloc.reFetch!(),
|
||||
child: const Text('Erneut versuchen'),
|
||||
),
|
||||
],
|
||||
if (technicalDetails != null) ...[
|
||||
const SizedBox(height: 4),
|
||||
TextButton(
|
||||
onPressed: () => InfoDialog.show(context, technicalDetails!),
|
||||
child: const Text('Details anzeigen'),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import '../../../../../widget/app_progress_indicator.dart';
|
||||
import 'loadable_state_consumer.dart';
|
||||
|
||||
class LoadableStatePrimaryLoading extends StatelessWidget {
|
||||
@@ -11,6 +12,6 @@ class LoadableStatePrimaryLoading extends StatelessWidget {
|
||||
opacity: visible ? 1.0 : 0.0,
|
||||
duration: LoadableStateConsumer.animationDuration,
|
||||
curve: Curves.easeInOut,
|
||||
child: const Center(child: CircularProgressIndicator()),
|
||||
child: const Center(child: AppProgressIndicator.large()),
|
||||
);
|
||||
}
|
||||
|
||||
+4
-2
@@ -2,6 +2,7 @@ import 'dart:developer';
|
||||
|
||||
import 'package:hydrated_bloc/hydrated_bloc.dart';
|
||||
|
||||
import '../../../../../api/errors/error_mapper.dart';
|
||||
import '../../loadableState/loading_error.dart';
|
||||
import '../../repository/repository.dart';
|
||||
import 'loadable_hydrated_bloc_event.dart';
|
||||
@@ -78,8 +79,9 @@ abstract class LoadableHydratedBloc<
|
||||
(e) {
|
||||
log('Error while fetching ${TState.toString()}: ${e.toString()}');
|
||||
add(Error(LoadingError(
|
||||
message: e.message ?? e.toString(),
|
||||
allowRetry: true,
|
||||
message: errorToUserMessage(e),
|
||||
technicalDetails: errorToTechnicalDetails(e),
|
||||
allowRetry: errorAllowsRetry(e),
|
||||
)));
|
||||
},
|
||||
).then((value) {
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import '../../../../../api/errors/error_mapper.dart';
|
||||
import '../../../../../api/marianumcloud/talk/chat/getChatResponse.dart';
|
||||
import '../../../infrastructure/loadableState/loading_error.dart';
|
||||
import '../../../infrastructure/utilityWidgets/loadableHydratedBloc/loadable_hydrated_bloc.dart';
|
||||
@@ -75,8 +76,9 @@ class ChatBloc extends LoadableHydratedBloc<ChatEvent, ChatState, ChatRepository
|
||||
}
|
||||
if (capturedError != null) {
|
||||
add(Error(LoadingError(
|
||||
message: capturedError.toString(),
|
||||
allowRetry: true,
|
||||
message: errorToUserMessage(capturedError),
|
||||
technicalDetails: errorToTechnicalDetails(capturedError),
|
||||
allowRetry: errorAllowsRetry(capturedError),
|
||||
)));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import 'package:flutter_app_badge/flutter_app_badge.dart';
|
||||
|
||||
import '../../../../../api/errors/error_mapper.dart';
|
||||
import '../../../infrastructure/loadableState/loading_error.dart';
|
||||
import '../../../infrastructure/utilityWidgets/loadableHydratedBloc/loadable_hydrated_bloc.dart';
|
||||
import '../../../infrastructure/utilityWidgets/loadableHydratedBloc/loadable_hydrated_bloc_event.dart';
|
||||
@@ -59,8 +60,9 @@ class ChatListBloc extends LoadableHydratedBloc<ChatListEvent, ChatListState, Ch
|
||||
}
|
||||
if (capturedError != null) {
|
||||
add(Error(LoadingError(
|
||||
message: capturedError.toString(),
|
||||
allowRetry: true,
|
||||
message: errorToUserMessage(capturedError),
|
||||
technicalDetails: errorToTechnicalDetails(capturedError),
|
||||
allowRetry: errorAllowsRetry(capturedError),
|
||||
)));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import '../../../../../api/errors/error_mapper.dart';
|
||||
import '../../../../../api/marianumcloud/webdav/queries/listFiles/listFilesResponse.dart';
|
||||
import '../../../infrastructure/loadableState/loading_error.dart';
|
||||
import '../../../infrastructure/utilityWidgets/loadableHydratedBloc/loadable_hydrated_bloc.dart';
|
||||
@@ -74,8 +75,9 @@ class FilesBloc extends LoadableHydratedBloc<FilesEvent, FilesState, FilesReposi
|
||||
}
|
||||
if (capturedError != null) {
|
||||
add(Error(LoadingError(
|
||||
message: capturedError.toString(),
|
||||
allowRetry: true,
|
||||
message: errorToUserMessage(capturedError),
|
||||
technicalDetails: errorToTechnicalDetails(capturedError),
|
||||
allowRetry: errorAllowsRetry(capturedError),
|
||||
)));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import 'dart:async';
|
||||
import 'dart:developer';
|
||||
|
||||
import 'package:easy_debounce/easy_debounce.dart';
|
||||
@@ -9,14 +10,23 @@ import '../../app_modules.dart';
|
||||
|
||||
class SettingsCubit extends HydratedCubit<Settings> {
|
||||
static const _debounceTag = 'settings_persist';
|
||||
bool _emitScheduled = false;
|
||||
|
||||
SettingsCubit() : super(DefaultSettings.get());
|
||||
|
||||
Settings val({bool write = false}) {
|
||||
if (write) {
|
||||
// Notify listeners immediately so the UI reflects the mutation right away;
|
||||
// debounce the actual persistence to disk to avoid hammering on rapid edits.
|
||||
// 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;
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -2,7 +2,6 @@ import 'dart:io';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
import 'package:loader_overlay/loader_overlay.dart';
|
||||
import 'package:persistent_bottom_nav_bar_v2/persistent_bottom_nav_bar_v2.dart';
|
||||
|
||||
import '../../../api/marianumcloud/webdav/queries/listFiles/cacheableFile.dart';
|
||||
@@ -12,6 +11,7 @@ import '../../../state/app/infrastructure/utilityWidgets/bloc_module.dart';
|
||||
import '../../../state/app/modules/files/bloc/files_bloc.dart';
|
||||
import '../../../state/app/modules/files/bloc/files_state.dart';
|
||||
import '../../../state/app/modules/settings/bloc/settings_cubit.dart';
|
||||
import '../../../widget/async_action_button.dart';
|
||||
import '../../../widget/file_pick.dart';
|
||||
import '../../../widget/placeholder_view.dart';
|
||||
import 'widgets/file_element.dart';
|
||||
@@ -175,12 +175,10 @@ class _FilesViewState extends State<_FilesView> {
|
||||
foldersToTop: context.watch<SettingsCubit>().val().fileSettings.sortFoldersToTop,
|
||||
reversed: currentSortDirection,
|
||||
);
|
||||
return LoaderOverlay(
|
||||
child: ListView.builder(
|
||||
return ListView.builder(
|
||||
padding: EdgeInsets.zero,
|
||||
itemCount: files.length,
|
||||
itemBuilder: (context, index) => FileElement(files[index], widget.path, bloc.refresh),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
@@ -233,15 +231,17 @@ class _FilesViewState extends State<_FilesView> {
|
||||
content: TextField(
|
||||
controller: inputController,
|
||||
decoration: const InputDecoration(labelText: 'Name'),
|
||||
autofocus: true,
|
||||
),
|
||||
actions: [
|
||||
TextButton(onPressed: () => Navigator.of(dialogCtx).pop(), child: const Text('Abbrechen')),
|
||||
TextButton(
|
||||
onPressed: () {
|
||||
bloc.createFolder(inputController.text);
|
||||
Navigator.of(dialogCtx).pop();
|
||||
AsyncDialogAction(
|
||||
confirmLabel: 'Ordner erstellen',
|
||||
onConfirm: () async {
|
||||
if (inputController.text.trim().isEmpty) {
|
||||
throw Exception('Bitte einen Namen eingeben.');
|
||||
}
|
||||
await bloc.createFolder(inputController.text.trim());
|
||||
},
|
||||
child: const Text('Ordner erstellen'),
|
||||
),
|
||||
],
|
||||
),
|
||||
|
||||
@@ -254,7 +254,7 @@ class _FilesUploadDialogState extends State<FilesUploadDialog> {
|
||||
border: const UnderlineInputBorder(),
|
||||
label: Text('Datei ${index+1}'),
|
||||
errorText: currentFile.isConflicting ? 'existiert bereits' : null,
|
||||
errorStyle: const TextStyle(color: Colors.red),
|
||||
errorStyle: TextStyle(color: Theme.of(context).colorScheme.error),
|
||||
),
|
||||
onChanged: (input) {
|
||||
currentFile.fileName = input;
|
||||
|
||||
@@ -159,11 +159,12 @@ class _FileElementState extends State<FileElement> {
|
||||
showDialog(context: context, builder: (context) => ConfirmDialog(
|
||||
title: 'Element löschen?',
|
||||
content: 'Das Element wird unwiederruflich gelöscht.',
|
||||
onConfirm: () {
|
||||
WebdavApi.webdav
|
||||
.then((value) => value.delete(PathUri.parse(widget.file.path)))
|
||||
.then((value) => widget.refetch());
|
||||
}
|
||||
confirmButton: 'Löschen',
|
||||
onConfirmAsync: () async {
|
||||
final webdav = await WebdavApi.webdav;
|
||||
await webdav.delete(PathUri.parse(widget.file.path));
|
||||
widget.refetch();
|
||||
},
|
||||
));
|
||||
},
|
||||
),
|
||||
|
||||
@@ -16,7 +16,7 @@ class AboutSection extends StatelessWidget {
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final settings = context.read<SettingsCubit>();
|
||||
final settings = context.watch<SettingsCubit>();
|
||||
return Column(
|
||||
children: [
|
||||
ListTile(
|
||||
|
||||
@@ -22,11 +22,11 @@ class AccountSection extends StatelessWidget {
|
||||
void _showLogoutDialog(BuildContext context) {
|
||||
showDialog(
|
||||
context: context,
|
||||
builder: (context) => ConfirmDialog(
|
||||
builder: (dialogContext) => ConfirmDialog(
|
||||
title: 'Abmelden?',
|
||||
content: 'Möchtest du dich wirklich abmelden?',
|
||||
confirmButton: 'Abmelden',
|
||||
onConfirm: () async {
|
||||
onConfirmAsync: () async {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
await prefs.clear();
|
||||
PaintingBinding.instance.imageCache.clear();
|
||||
|
||||
@@ -9,7 +9,7 @@ class AppearanceSection extends StatelessWidget {
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final settings = context.read<SettingsCubit>();
|
||||
final settings = context.watch<SettingsCubit>();
|
||||
return ListTile(
|
||||
leading: const Icon(Icons.dark_mode_outlined),
|
||||
title: const Text('Farbgebung'),
|
||||
|
||||
@@ -6,6 +6,7 @@ import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
|
||||
import '../../../../routing/app_routes.dart';
|
||||
import '../../../../state/app/modules/settings/bloc/settings_cubit.dart';
|
||||
import '../../../../storage/settings.dart' as model;
|
||||
import '../../../../widget/centered_leading.dart';
|
||||
import '../../../../widget/confirm_dialog.dart';
|
||||
import '../../../../widget/debug/cache_view.dart';
|
||||
@@ -28,13 +29,19 @@ class _DevToolsSectionState extends State<DevToolsSection> {
|
||||
title: const Text('Performance overlays'),
|
||||
trailing: const Icon(Icons.arrow_right),
|
||||
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: [
|
||||
ListTile(
|
||||
leading: const Icon(Icons.auto_graph_outlined),
|
||||
title: const Text('Performance graph'),
|
||||
trailing: Checkbox(
|
||||
value: widget.settings.val().devToolsSettings.showPerformanceOverlay,
|
||||
value: dev.showPerformanceOverlay,
|
||||
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),
|
||||
title: const Text('Indicate offscreen layers'),
|
||||
trailing: Checkbox(
|
||||
value: widget.settings.val().devToolsSettings.checkerboardOffscreenLayers,
|
||||
value: dev.checkerboardOffscreenLayers,
|
||||
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),
|
||||
title: const Text('Indicate raster cache images'),
|
||||
trailing: Checkbox(
|
||||
value: widget.settings.val().devToolsSettings.checkerboardRasterCacheImages,
|
||||
value: dev.checkerboardRasterCacheImages,
|
||||
onChanged: (e) => widget.settings.val(write: true).devToolsSettings.checkerboardRasterCacheImages = e!,
|
||||
),
|
||||
),
|
||||
],
|
||||
));
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
ListTile(
|
||||
|
||||
@@ -8,7 +8,7 @@ class FilesSection extends StatelessWidget {
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final settings = context.read<SettingsCubit>();
|
||||
final settings = context.watch<SettingsCubit>();
|
||||
return Column(
|
||||
children: [
|
||||
ListTile(
|
||||
|
||||
@@ -10,7 +10,7 @@ class TalkSection extends StatelessWidget {
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final settings = context.read<SettingsCubit>();
|
||||
final settings = context.watch<SettingsCubit>();
|
||||
final talkSettings = settings.val().talkSettings;
|
||||
final notificationSettings = settings.val().notificationSettings;
|
||||
return Column(
|
||||
|
||||
@@ -2,7 +2,6 @@ import 'package:flutter/material.dart';
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
|
||||
import '../../../../state/app/modules/settings/bloc/settings_cubit.dart';
|
||||
import '../../../../state/app/modules/timetable/bloc/timetable_bloc.dart';
|
||||
import '../../../../view/pages/timetable/data/timetable_name_mode.dart';
|
||||
|
||||
class TimetableSection extends StatelessWidget {
|
||||
@@ -10,8 +9,7 @@ class TimetableSection extends StatelessWidget {
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final settings = context.read<SettingsCubit>();
|
||||
final timetableBloc = context.read<TimetableBloc>();
|
||||
final settings = context.watch<SettingsCubit>();
|
||||
final timetableSettings = settings.val().timetableSettings;
|
||||
return Column(
|
||||
children: [
|
||||
@@ -34,10 +32,8 @@ class TimetableSection extends StatelessWidget {
|
||||
),
|
||||
))
|
||||
.toList(),
|
||||
onChanged: (value) {
|
||||
settings.val(write: true).timetableSettings.timetableNameMode = value!;
|
||||
timetableBloc.refresh();
|
||||
},
|
||||
onChanged: (value) =>
|
||||
settings.val(write: true).timetableSettings.timetableNameMode = value!,
|
||||
),
|
||||
),
|
||||
ListTile(
|
||||
@@ -45,10 +41,8 @@ class TimetableSection extends StatelessWidget {
|
||||
title: const Text('Doppelstunden zusammenhängend anzeigen'),
|
||||
trailing: Checkbox(
|
||||
value: timetableSettings.connectDoubleLessons,
|
||||
onChanged: (e) {
|
||||
settings.val(write: true).timetableSettings.connectDoubleLessons = e!;
|
||||
timetableBloc.refresh();
|
||||
},
|
||||
onChanged: (e) =>
|
||||
settings.val(write: true).timetableSettings.connectDoubleLessons = e!,
|
||||
),
|
||||
),
|
||||
],
|
||||
|
||||
@@ -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,8 +11,7 @@ class Settings extends StatelessWidget {
|
||||
const Settings({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) => BlocBuilder<SettingsCubit, model.Settings>(
|
||||
builder: (context, _) => Scaffold(
|
||||
Widget build(BuildContext context) => Scaffold(
|
||||
appBar: AppBar(title: const Text('Einstellungen')),
|
||||
body: ListView(
|
||||
children: const [
|
||||
@@ -32,6 +28,5 @@ class Settings extends StatelessWidget {
|
||||
AboutSection(),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -2,9 +2,11 @@
|
||||
import 'package:async/async.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import '../../../api/errors/error_mapper.dart';
|
||||
import '../../../api/marianumcloud/autocomplete/autocompleteApi.dart';
|
||||
import '../../../api/marianumcloud/autocomplete/autocompleteResponse.dart';
|
||||
import '../../../model/endpoint_data.dart';
|
||||
import '../../../widget/app_progress_indicator.dart';
|
||||
import '../../../widget/placeholder_view.dart';
|
||||
|
||||
class JoinChat extends SearchDelegate<String> {
|
||||
@@ -16,17 +18,9 @@ class JoinChat extends SearchDelegate<String> {
|
||||
future: future!.value,
|
||||
builder: (context, snapshot) {
|
||||
if(snapshot.connectionState != ConnectionState.done) {
|
||||
return Container(
|
||||
padding: const EdgeInsets.all(10),
|
||||
child: const Center(
|
||||
child: SizedBox(
|
||||
height: 20,
|
||||
width: 20,
|
||||
child: CircularProgressIndicator(
|
||||
strokeWidth: 3,
|
||||
),
|
||||
),
|
||||
),
|
||||
return const Padding(
|
||||
padding: EdgeInsets.all(10),
|
||||
child: Center(child: AppProgressIndicator.medium()),
|
||||
);
|
||||
}
|
||||
return const SizedBox.shrink();
|
||||
@@ -76,10 +70,13 @@ class JoinChat extends SearchDelegate<String> {
|
||||
}
|
||||
);
|
||||
} else if(snapshot.hasError) {
|
||||
return const PlaceholderView(icon: Icons.search_off, text: 'Ein fehler ist aufgetreten. Bist du mit dem Internet verbunden?');
|
||||
return PlaceholderView(
|
||||
icon: Icons.search_off,
|
||||
text: errorToUserMessage(snapshot.error),
|
||||
);
|
||||
}
|
||||
|
||||
return const Center(child: CircularProgressIndicator());
|
||||
return const Center(child: AppProgressIndicator.large());
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
@@ -14,6 +14,7 @@ import '../../../../api/marianumcloud/talk/reactMessage/reactMessageParams.dart'
|
||||
import '../../../../api/marianumcloud/talk/room/getRoomResponse.dart';
|
||||
import '../../../../extensions/text.dart';
|
||||
import '../../../../state/app/modules/chat/bloc/chat_bloc.dart';
|
||||
import '../../../../widget/async_action_button.dart';
|
||||
import '../../../../widget/loading_spinner.dart';
|
||||
import '../../files/widgets/file_element.dart';
|
||||
import '../data/chat_bubble_styles.dart';
|
||||
@@ -306,22 +307,22 @@ class _ChatBubbleState extends State<ChatBubble> with SingleTickerProviderStateM
|
||||
padding: EdgeInsets.zero,
|
||||
backgroundColor: hasSelfReacted ? Theme.of(context).primaryColor : null,
|
||||
onPressed: () {
|
||||
runWithErrorDialog(context, () async {
|
||||
if (hasSelfReacted) {
|
||||
// Delete existing reaction
|
||||
DeleteReactMessage(
|
||||
await DeleteReactMessage(
|
||||
chatToken: widget.chatData.token,
|
||||
messageId: widget.bubbleData.id,
|
||||
params: DeleteReactMessageParams(e.key),
|
||||
).run().then((value) => widget.refetch(renew: true));
|
||||
|
||||
).run();
|
||||
} else {
|
||||
// Add reaction
|
||||
ReactMessage(
|
||||
await ReactMessage(
|
||||
chatToken: widget.chatData.token,
|
||||
messageId: widget.bubbleData.id,
|
||||
params: ReactMessageParams(e.key)
|
||||
).run().then((value) => widget.refetch(renew: true));
|
||||
params: ReactMessageParams(e.key),
|
||||
).run();
|
||||
}
|
||||
widget.refetch(renew: true);
|
||||
});
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
@@ -11,6 +11,8 @@ import '../../../../api/marianumcloud/talk/reactMessage/reactMessageParams.dart'
|
||||
import '../../../../api/marianumcloud/talk/room/getRoomResponse.dart';
|
||||
import '../../../../routing/app_routes.dart';
|
||||
import '../../../../state/app/modules/chat/bloc/chat_bloc.dart';
|
||||
import '../../../../widget/app_progress_indicator.dart';
|
||||
import '../../../../widget/async_action_button.dart';
|
||||
import '../../../../widget/debug/debug_tile.dart';
|
||||
|
||||
const _commonReactions = <String>['👍', '👎', '😆', '❤️', '👀'];
|
||||
@@ -78,14 +80,12 @@ Future<void> showChatMessageOptionsDialog(
|
||||
onTap: () => Navigator.of(dialogCtx).pop(),
|
||||
),
|
||||
if (canDelete)
|
||||
ListTile(
|
||||
AsyncListTile(
|
||||
leading: const Icon(Icons.delete_outline),
|
||||
title: const Text('Nachricht löschen'),
|
||||
onTap: () async {
|
||||
onPressed: () async {
|
||||
await DeleteMessage(chatData.token, bubbleData.id).run();
|
||||
if (!dialogCtx.mounted) return;
|
||||
dialogCtx.read<ChatBloc>().refresh();
|
||||
Navigator.of(dialogCtx).pop();
|
||||
if (dialogCtx.mounted) dialogCtx.read<ChatBloc>().refresh();
|
||||
},
|
||||
),
|
||||
DebugTile(dialogCtx).jsonData(bubbleData.toJson()),
|
||||
@@ -94,7 +94,7 @@ Future<void> showChatMessageOptionsDialog(
|
||||
);
|
||||
}
|
||||
|
||||
class _ReactionsRow extends StatelessWidget {
|
||||
class _ReactionsRow extends StatefulWidget {
|
||||
final String chatToken;
|
||||
final int messageId;
|
||||
final void Function({bool renew}) onRefetch;
|
||||
@@ -107,17 +107,41 @@ class _ReactionsRow extends StatelessWidget {
|
||||
required this.dialogContext,
|
||||
});
|
||||
|
||||
void _react(String emoji) {
|
||||
Navigator.of(dialogContext).pop();
|
||||
ReactMessage(
|
||||
chatToken: chatToken,
|
||||
messageId: messageId,
|
||||
@override
|
||||
State<_ReactionsRow> createState() => _ReactionsRowState();
|
||||
}
|
||||
|
||||
class _ReactionsRowState extends State<_ReactionsRow> {
|
||||
final AsyncActionController _controller = AsyncActionController();
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_controller.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
Future<void> _react(String emoji) async {
|
||||
final ok = await _controller.run(() async {
|
||||
await ReactMessage(
|
||||
chatToken: widget.chatToken,
|
||||
messageId: widget.messageId,
|
||||
params: ReactMessageParams(emoji),
|
||||
).run().then((_) => onRefetch(renew: true));
|
||||
).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(
|
||||
Widget build(BuildContext context) => AnimatedBuilder(
|
||||
animation: _controller,
|
||||
builder: (context, _) {
|
||||
final busy = _controller.busy;
|
||||
final err = _controller.error;
|
||||
return Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Wrap(
|
||||
@@ -130,24 +154,37 @@ class _ReactionsRow extends StatelessWidget {
|
||||
tapTargetSize: MaterialTapTargetSize.shrinkWrap,
|
||||
minimumSize: const Size(40, 40),
|
||||
),
|
||||
onPressed: () => _react(emoji),
|
||||
onPressed: busy ? null : () => _react(emoji),
|
||||
child: Text(emoji),
|
||||
),
|
||||
),
|
||||
IconButton(
|
||||
onPressed: () => _showEmojiPicker(context),
|
||||
onPressed: busy ? null : () => _showEmojiPicker(context),
|
||||
style: IconButton.styleFrom(
|
||||
padding: EdgeInsets.zero,
|
||||
tapTargetSize: MaterialTapTargetSize.shrinkWrap,
|
||||
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(),
|
||||
],
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
void _showEmojiPicker(BuildContext rowContext) {
|
||||
showDialog(
|
||||
|
||||
@@ -12,6 +12,7 @@ import '../../../../api/marianumcloud/talk/sendMessage/sendMessageParams.dart';
|
||||
import '../../../../api/marianumcloud/webdav/webdavApi.dart';
|
||||
import '../../../../state/app/modules/chat/bloc/chat_bloc.dart';
|
||||
import '../../../../state/app/modules/settings/bloc/settings_cubit.dart';
|
||||
import '../../../../widget/async_action_button.dart';
|
||||
import '../../../../widget/file_pick.dart';
|
||||
import '../../../../widget/focus_behaviour.dart';
|
||||
import '../../files/files_upload_dialog.dart';
|
||||
@@ -30,7 +31,8 @@ class ChatTextfield extends StatefulWidget {
|
||||
class _ChatTextfieldState extends State<ChatTextfield> {
|
||||
late SettingsCubit settings;
|
||||
final TextEditingController _textBoxController = TextEditingController();
|
||||
bool isLoading = false;
|
||||
final AsyncActionController _sendController = AsyncActionController();
|
||||
String? _sendError;
|
||||
|
||||
void share(String shareFolder, List<String> filePaths) {
|
||||
for (final element in filePaths) {
|
||||
@@ -92,6 +94,29 @@ class _ChatTextfieldState extends State<ChatTextfield> {
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_sendController.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
Future<void> _sendMessage(ChatBloc chatBloc) async {
|
||||
if (_textBoxController.text.isEmpty) return;
|
||||
final text = _textBoxController.text;
|
||||
final replyTo = chatBloc.state.data?.referenceMessageId?.toString();
|
||||
setState(() => _sendError = null);
|
||||
await SendMessage(
|
||||
widget.sendToToken,
|
||||
SendMessageParams(text, replyTo: replyTo),
|
||||
).run();
|
||||
if (!mounted) return;
|
||||
chatBloc.refresh();
|
||||
_textBoxController.text = '';
|
||||
_setDraft('');
|
||||
chatBloc.setReferenceMessageId(null);
|
||||
_setDraftReply(null);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
_textBoxController.text = settings.val().talkSettings.drafts[widget.sendToToken] ?? '';
|
||||
@@ -135,6 +160,14 @@ class _ChatTextfieldState extends State<ChatTextfield> {
|
||||
child: Column(
|
||||
children: [
|
||||
replyBanner,
|
||||
if (_sendError != null)
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(bottom: 4),
|
||||
child: Text(
|
||||
_sendError!,
|
||||
style: TextStyle(color: Theme.of(context).colorScheme.error, fontSize: 12),
|
||||
),
|
||||
),
|
||||
Row(children: <Widget>[
|
||||
GestureDetector(
|
||||
onTap: () {
|
||||
@@ -200,36 +233,19 @@ class _ChatTextfieldState extends State<ChatTextfield> {
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 15),
|
||||
FloatingActionButton(
|
||||
ValueListenableBuilder<TextEditingValue>(
|
||||
valueListenable: _textBoxController,
|
||||
builder: (context, value, _) => AsyncFab(
|
||||
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);
|
||||
});
|
||||
},
|
||||
heroTag: 'chatSend_${widget.sendToToken}',
|
||||
icon: Icons.send,
|
||||
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),
|
||||
foregroundColor: Colors.white,
|
||||
controller: _sendController,
|
||||
onPressed: value.text.trim().isEmpty ? null : () => _sendMessage(chatBloc),
|
||||
onError: (message) => setState(() => _sendError = message),
|
||||
onSuccess: () => setState(() => _sendError = null),
|
||||
),
|
||||
),
|
||||
]),
|
||||
],
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
@@ -11,6 +12,7 @@ import '../../../../api/marianumcloud/talk/setReadMarker/setReadMarkerParams.dar
|
||||
import '../../../../model/account_data.dart';
|
||||
import '../../../../state/app/modules/chat/bloc/chat_bloc.dart';
|
||||
import '../../../../state/app/modules/chatList/bloc/chat_list_bloc.dart';
|
||||
import '../../../../widget/async_action_button.dart';
|
||||
import '../../../../widget/confirm_dialog.dart';
|
||||
import '../../../../widget/debug/debug_tile.dart';
|
||||
import '../../../../widget/user_avatar.dart';
|
||||
@@ -42,15 +44,14 @@ class _ChatTileState extends State<ChatTile> {
|
||||
|
||||
void _refreshList() => context.read<ChatListBloc>().refresh();
|
||||
|
||||
void setCurrentAsRead() {
|
||||
SetReadMarker(
|
||||
Future<void> _setCurrentAsRead() async {
|
||||
await SetReadMarker(
|
||||
widget.data.token,
|
||||
true,
|
||||
setReadMarkerParams: SetReadMarkerParams(lastReadMessage: widget.data.lastMessage.id),
|
||||
).run().then((_) {
|
||||
).run();
|
||||
if (!mounted) return;
|
||||
_refreshList();
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
@@ -116,7 +117,7 @@ class _ChatTileState extends State<ChatTile> {
|
||||
),
|
||||
onTap: () {
|
||||
if (selfUsername == null) return;
|
||||
setCurrentAsRead();
|
||||
unawaited(_setCurrentAsRead());
|
||||
final view = ChatView(room: widget.data, selfId: selfUsername!, avatar: circleAvatar);
|
||||
TalkNavigator.pushSplitView(context, view, overrideToSingleSubScreen: true);
|
||||
context.read<ChatBloc>().setToken(widget.data.token);
|
||||
@@ -125,65 +126,53 @@ class _ChatTileState extends State<ChatTile> {
|
||||
if (widget.disableContextActions) return;
|
||||
showDialog(context: context, builder: (dialogCtx) => SimpleDialog(
|
||||
children: [
|
||||
Visibility(
|
||||
visible: widget.data.unreadMessages > 0,
|
||||
replacement: ListTile(
|
||||
leading: const Icon(Icons.mark_chat_unread_outlined),
|
||||
title: const Text('Als ungelesen markieren'),
|
||||
onTap: () {
|
||||
SetReadMarker(widget.data.token, false).run().then((_) {
|
||||
if (mounted) _refreshList();
|
||||
});
|
||||
Navigator.of(dialogCtx).pop();
|
||||
},
|
||||
),
|
||||
child: ListTile(
|
||||
if (widget.data.unreadMessages > 0)
|
||||
AsyncListTile(
|
||||
leading: const Icon(Icons.mark_chat_read_outlined),
|
||||
title: const Text('Als gelesen markieren'),
|
||||
onTap: () {
|
||||
setCurrentAsRead();
|
||||
Navigator.of(dialogCtx).pop();
|
||||
},
|
||||
),
|
||||
),
|
||||
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((_) {
|
||||
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();
|
||||
});
|
||||
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((_) {
|
||||
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();
|
||||
});
|
||||
Navigator.of(dialogCtx).pop();
|
||||
},
|
||||
),
|
||||
),
|
||||
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((_) {
|
||||
confirmButton: 'Verlassen',
|
||||
onConfirmAsync: () async {
|
||||
await LeaveRoom(widget.data.token).run();
|
||||
if (mounted) _refreshList();
|
||||
});
|
||||
Navigator.of(dialogCtx).pop();
|
||||
},
|
||||
).asDialog(dialogCtx);
|
||||
).asDialog(context);
|
||||
},
|
||||
),
|
||||
DebugTile(dialogCtx).jsonData(widget.data.toJson()),
|
||||
|
||||
@@ -15,9 +15,7 @@ Completer<void> showDeleteCustomEventDialog(BuildContext context, CustomTimetabl
|
||||
content: 'Der ${event.rrule.isEmpty ? "Termin" : "Serientermin"} wird unwiederruflich gelöscht.',
|
||||
confirmButton: 'Löschen',
|
||||
onConfirm: () {
|
||||
bloc.removeCustomEvent(event.id).then(completer.complete).onError((Object error, StackTrace stack) {
|
||||
completer.completeError(error, stack);
|
||||
});
|
||||
bloc.removeCustomEvent(event.id).then(completer.complete).onError(completer.completeError);
|
||||
},
|
||||
).asDialog(context);
|
||||
return completer;
|
||||
|
||||
@@ -8,6 +8,7 @@ import '../../../state/app/infrastructure/loadableState/view/loadable_state_cons
|
||||
import '../../../state/app/modules/timetable/bloc/timetable_bloc.dart';
|
||||
import '../../../state/app/modules/timetable/bloc/timetable_state.dart';
|
||||
import '../../../state/app/modules/settings/bloc/settings_cubit.dart';
|
||||
import '../../../storage/timetable_settings.dart';
|
||||
import 'custom_events/custom_event_edit_dialog.dart';
|
||||
import 'data/arbitrary_appointment.dart';
|
||||
import 'data/lesson_period_schedule.dart';
|
||||
@@ -30,6 +31,7 @@ class _TimetableState extends State<Timetable> {
|
||||
|
||||
List<Appointment>? _cachedAppointments;
|
||||
int? _lastDataVersion;
|
||||
TimetableSettings? _lastTimetableSettings;
|
||||
|
||||
DateTime _initialDisplayDate() => DateTime.now().add(const Duration(days: 2));
|
||||
|
||||
@@ -51,18 +53,21 @@ class _TimetableState extends State<Timetable> {
|
||||
}
|
||||
|
||||
List<Appointment> _appointments(TimetableState state) {
|
||||
if (_cachedAppointments != null && _lastDataVersion == state.dataVersion) {
|
||||
final timetableSettings = context.watch<SettingsCubit>().val().timetableSettings;
|
||||
if (_cachedAppointments != null &&
|
||||
_lastDataVersion == state.dataVersion &&
|
||||
identical(_lastTimetableSettings, timetableSettings)) {
|
||||
return _cachedAppointments!;
|
||||
}
|
||||
_lastDataVersion = state.dataVersion;
|
||||
_lastTimetableSettings = timetableSettings;
|
||||
|
||||
final settings = context.read<SettingsCubit>();
|
||||
return _cachedAppointments = TimetableAppointmentFactory(
|
||||
lessons: state.getAllKnownLessons().toList(),
|
||||
customEvents: state.customEvents?.events ?? const [],
|
||||
rooms: state.rooms!,
|
||||
subjects: state.subjects!,
|
||||
settings: settings.val().timetableSettings,
|
||||
settings: timetableSettings,
|
||||
now: DateTime.now(),
|
||||
).build();
|
||||
}
|
||||
|
||||
@@ -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),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
@@ -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);
|
||||
@@ -19,14 +35,27 @@ class ConfirmDialog extends StatelessWidget {
|
||||
icon: icon != null ? Icon(icon) : null,
|
||||
title: Text(title),
|
||||
content: Text(content),
|
||||
actions: [
|
||||
TextButton(onPressed: () {
|
||||
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();
|
||||
}, child: Text(cancelButton)),
|
||||
TextButton(onPressed: () {
|
||||
Navigator.of(context).pop();
|
||||
onConfirm();
|
||||
}, child: Text(confirmButton)),
|
||||
onConfirm!();
|
||||
},
|
||||
child: Text(confirmButton),
|
||||
),
|
||||
],
|
||||
);
|
||||
|
||||
|
||||
Reference in New Issue
Block a user