refactored data providers with centralized cache resolution, unified UI using custom dialogs and bottom sheets, and enhanced network error handling for Dio and TLS errors

This commit is contained in:
2026-05-08 20:01:45 +02:00
parent c62a14645a
commit 9e139b5704
37 changed files with 595 additions and 753 deletions
+51 -1
View File
@@ -1,6 +1,7 @@
import 'dart:async'; import 'dart:async';
import 'dart:io'; import 'dart:io';
import 'package:dio/dio.dart';
import 'package:http/http.dart' as http; import 'package:http/http.dart' as http;
import '../api_error.dart'; import '../api_error.dart';
@@ -9,10 +10,46 @@ import '../webuntis/webuntis_error.dart';
import 'app_exception.dart'; import 'app_exception.dart';
import 'network_exception.dart'; import 'network_exception.dart';
import 'parse_exception.dart'; import 'parse_exception.dart';
import 'server_exception.dart';
import 'talk_exception.dart'; import 'talk_exception.dart';
import 'webuntis_exception.dart'; import 'webuntis_exception.dart';
const String _defaultFallback = 'Etwas ist schiefgelaufen. Bitte versuche es erneut.'; const String _defaultFallback = 'Etwas ist schiefgelaufen. Bitte versuche es erneut.';
const String _tlsErrorMessage =
'Die sichere Verbindung zum Server wurde abgelehnt (Zertifikat oder TLS-Fehler). '
'Häufige Ursachen: falsche Geräte-Uhrzeit oder ein WLAN mit Anmeldeseite (z.B. Café/Hotel).';
AppException? _dioToAppException(DioException error) {
switch (error.type) {
case DioExceptionType.connectionTimeout:
case DioExceptionType.sendTimeout:
case DioExceptionType.receiveTimeout:
return NetworkException.timeout(technicalDetails: error.message);
case DioExceptionType.connectionError:
return NetworkException(technicalDetails: error.message);
case DioExceptionType.badCertificate:
return const NetworkException(
userMessage: _tlsErrorMessage,
);
case DioExceptionType.badResponse:
final status = error.response?.statusCode;
return ServerException(
statusCode: status ?? -1,
technicalDetails: 'HTTP $status: ${error.message}',
);
case DioExceptionType.cancel:
case DioExceptionType.unknown:
final inner = error.error;
if (inner is SocketException) return NetworkException(technicalDetails: inner.message);
if (inner is HandshakeException) {
return const NetworkException(
userMessage: _tlsErrorMessage,
);
}
if (inner is FormatException) return ParseException(technicalDetails: inner.message);
return null;
}
}
String errorToUserMessage(Object? error, {String fallback = _defaultFallback}) { String errorToUserMessage(Object? error, {String fallback = _defaultFallback}) {
if (error == null) return fallback; if (error == null) return fallback;
@@ -21,6 +58,11 @@ String errorToUserMessage(Object? error, {String fallback = _defaultFallback}) {
if (error is TalkError) return TalkException(error).userMessage; if (error is TalkError) return TalkException(error).userMessage;
if (error is WebuntisError) return WebuntisException(error).userMessage; if (error is WebuntisError) return WebuntisException(error).userMessage;
if (error is DioException) {
final mapped = _dioToAppException(error);
if (mapped != null) return mapped.userMessage;
}
if (error is SocketException) { if (error is SocketException) {
return const NetworkException().userMessage; return const NetworkException().userMessage;
} }
@@ -31,7 +73,7 @@ String errorToUserMessage(Object? error, {String fallback = _defaultFallback}) {
return const NetworkException().userMessage; return const NetworkException().userMessage;
} }
if (error is HandshakeException) { if (error is HandshakeException) {
return 'Sichere Verbindung konnte nicht hergestellt werden.'; return _tlsErrorMessage;
} }
if (error is FormatException) { if (error is FormatException) {
return const ParseException().userMessage; return const ParseException().userMessage;
@@ -48,12 +90,20 @@ String? errorToTechnicalDetails(Object? error) {
if (error is AppException) return error.technicalDetails ?? error.toString(); if (error is AppException) return error.technicalDetails ?? error.toString();
if (error is TalkError) return TalkException(error).technicalDetails; if (error is TalkError) return TalkException(error).technicalDetails;
if (error is WebuntisError) return WebuntisException(error).technicalDetails; if (error is WebuntisError) return WebuntisException(error).technicalDetails;
if (error is DioException) {
final mapped = _dioToAppException(error);
if (mapped != null) return mapped.technicalDetails ?? mapped.toString();
}
return error.toString(); return error.toString();
} }
bool errorAllowsRetry(Object? error) { bool errorAllowsRetry(Object? error) {
if (error == null) return true; if (error == null) return true;
if (error is AppException) return error.allowRetry; if (error is AppException) return error.allowRetry;
if (error is DioException) {
final mapped = _dioToAppException(error);
if (mapped != null) return mapped.allowRetry;
}
return true; return true;
} }
-16
View File
@@ -1,16 +0,0 @@
import 'dart:convert';
import 'package:http/http.dart' as http;
import 'get_holidays_response.dart';
class GetHolidays {
Future<GetHolidaysResponse> query() async {
var response = (await http.get(Uri.parse('https://ferien-api.de/api/v1/holidays/HE'))).body;
var data = jsonDecode(response) as List<dynamic>;
return GetHolidaysResponse(
List<GetHolidaysResponseObject>.from(
data.map<GetHolidaysResponseObject>((e) => GetHolidaysResponseObject.fromJson(e as Map<String, dynamic>))
)
);
}
}
-18
View File
@@ -1,18 +0,0 @@
import '../request_cache.dart';
import 'get_holidays.dart';
import 'get_holidays_response.dart';
class GetHolidaysCache extends SimpleCache<GetHolidaysResponse> {
GetHolidaysCache({super.onUpdate, super.renew})
: super(
cacheTime: RequestCache.cacheDay,
loader: () => GetHolidays().query(),
fromJson: (json) => GetHolidaysResponse(
(json['data'] as List)
.map((i) => GetHolidaysResponseObject.fromJson(i as Map<String, dynamic>))
.toList(),
),
) {
start('state-holidays');
}
}
@@ -1,38 +0,0 @@
import 'package:json_annotation/json_annotation.dart';
import '../api_response.dart';
part 'get_holidays_response.g.dart';
@JsonSerializable(explicitToJson: true)
class GetHolidaysResponse extends ApiResponse {
List<GetHolidaysResponseObject> data;
GetHolidaysResponse(this.data);
factory GetHolidaysResponse.fromJson(Map<String, dynamic> json) => _$GetHolidaysResponseFromJson(json);
Map<String, dynamic> toJson() => _$GetHolidaysResponseToJson(this);
}
@JsonSerializable()
class GetHolidaysResponseObject {
String start;
String end;
int year;
String stateCode;
String name;
String slug;
GetHolidaysResponseObject({
required this.start,
required this.end,
required this.year,
required this.stateCode,
required this.name,
required this.slug
});
factory GetHolidaysResponseObject.fromJson(Map<String, dynamic> json) => _$GetHolidaysResponseObjectFromJson(json);
Map<String, dynamic> toJson() => _$GetHolidaysResponseObjectToJson(this);
}
@@ -1,49 +0,0 @@
// GENERATED CODE - DO NOT MODIFY BY HAND
part of 'get_holidays_response.dart';
// **************************************************************************
// JsonSerializableGenerator
// **************************************************************************
GetHolidaysResponse _$GetHolidaysResponseFromJson(Map<String, dynamic> json) =>
GetHolidaysResponse(
(json['data'] as List<dynamic>)
.map(
(e) =>
GetHolidaysResponseObject.fromJson(e as Map<String, dynamic>),
)
.toList(),
)
..headers = (json['headers'] as Map<String, dynamic>?)?.map(
(k, e) => MapEntry(k, e as String),
);
Map<String, dynamic> _$GetHolidaysResponseToJson(
GetHolidaysResponse instance,
) => <String, dynamic>{
'headers': ?instance.headers,
'data': instance.data.map((e) => e.toJson()).toList(),
};
GetHolidaysResponseObject _$GetHolidaysResponseObjectFromJson(
Map<String, dynamic> json,
) => GetHolidaysResponseObject(
start: json['start'] as String,
end: json['end'] as String,
year: (json['year'] as num).toInt(),
stateCode: json['stateCode'] as String,
name: json['name'] as String,
slug: json['slug'] as String,
);
Map<String, dynamic> _$GetHolidaysResponseObjectToJson(
GetHolidaysResponseObject instance,
) => <String, dynamic>{
'start': instance.start,
'end': instance.end,
'year': instance.year,
'stateCode': instance.stateCode,
'name': instance.name,
'slug': instance.slug,
};
+2 -12
View File
@@ -1,27 +1,17 @@
import '../../model/account_data.dart'; import '../../model/account_data.dart';
import '../../model/endpoint_data.dart'; import '../../model/endpoint_data.dart';
/// Shared helpers for Nextcloud OCS v2 endpoints. /// Shared headers and URI builder for Nextcloud OCS v2 endpoints. Used by
/// /// TalkApi, AutocompleteApi, FileSharingApi.
/// Three call sites previously duplicated the same header dictionary and the
/// same URI scaffolding (TalkApi, AutocompleteApi, FileSharingApi). Anything
/// that talks to `https://<domain>/<base>/ocs/v2.php/...` should go through
/// these two helpers so additions like a new header or a different auth
/// scheme only need to change here.
class NextcloudOcs { class NextcloudOcs {
NextcloudOcs._(); NextcloudOcs._();
/// The standard OCS request header set: JSON accept, OCS API marker,
/// HTTP Basic auth from the active [AccountData].
static Map<String, String> headers() => { static Map<String, String> headers() => {
'Accept': 'application/json', 'Accept': 'application/json',
'OCS-APIRequest': 'true', 'OCS-APIRequest': 'true',
'Authorization': AccountData().getBasicAuthHeader(), 'Authorization': AccountData().getBasicAuthHeader(),
}; };
/// Builds an OCS URI by appending [pathSuffix] under `/ocs/v2.php/` of
/// the configured Nextcloud endpoint. Query parameters are converted to
/// strings (Uri rejects non-string values).
static Uri uri(String pathSuffix, {Map<String, dynamic>? queryParameters}) { static Uri uri(String pathSuffix, {Map<String, dynamic>? queryParameters}) {
final endpoint = EndpointData().nextcloud(); final endpoint = EndpointData().nextcloud();
return Uri.https( return Uri.https(
@@ -120,38 +120,6 @@ enum GetRoomResponseObjectParticipantNotificationLevel {
@JsonValue(3) neverNotify, @JsonValue(3) neverNotify,
} }
// @JsonSerializable(explicitToJson: true)
// class GetRoomResponseObjectMessage {
// int id;
// String token;
// GetRoomResponseObjectMessageActorType actorType;
// String actorId;
// String actorDisplayName;
// int timestamp;
// String message;
// String systemMessage;
// GetRoomResponseObjectMessageType messageType;
// bool isReplyable;
// String referenceId;
//
//
// GetRoomResponseObjectMessage(
// this.id,
// this.token,
// this.actorType,
// this.actorId,
// this.actorDisplayName,
// this.timestamp,
// this.message,
// this.systemMessage,
// this.messageType,
// this.isReplyable,
// this.referenceId);
//
// factory GetRoomResponseObjectMessage.fromJson(Map<String, dynamic> json) => _$GetRoomResponseObjectMessageFromJson(json);
// Map<String, dynamic> toJson() => _$GetRoomResponseObjectMessageToJson(this);
// }
enum GetRoomResponseObjectMessageActorType { enum GetRoomResponseObjectMessageActorType {
@JsonValue('deleted_users') deletedUsers, @JsonValue('deleted_users') deletedUsers,
@JsonValue('users') user, @JsonValue('users') user,
+30 -4
View File
@@ -4,6 +4,7 @@ import 'dart:convert';
import 'package:localstore/localstore.dart'; import 'package:localstore/localstore.dart';
import 'api_response.dart'; import 'api_response.dart';
import 'errors/parse_exception.dart';
abstract class RequestCache<T extends ApiResponse?> { abstract class RequestCache<T extends ApiResponse?> {
static const int cacheNothing = 0; static const int cacheNothing = 0;
@@ -81,10 +82,8 @@ abstract class RequestCache<T extends ApiResponse?> {
} }
/// Concrete [RequestCache] that delegates the two overrides to functions /// Concrete [RequestCache] that takes the two overrides as constructor
/// passed in the constructor. Used to collapse the dozens of one-class-per- /// callbacks instead of requiring a subclass per endpoint.
/// endpoint cache files that all just forward to `<Endpoint>().run()` and
/// `<Response>.fromJson(jsonDecode(...))`.
class SimpleCache<T extends ApiResponse?> extends RequestCache<T> { class SimpleCache<T extends ApiResponse?> extends RequestCache<T> {
final Future<T> Function() _loader; final Future<T> Function() _loader;
final T Function(Map<String, dynamic> json) _fromJson; final T Function(Map<String, dynamic> json) _fromJson;
@@ -115,3 +114,30 @@ class SimpleCache<T extends ApiResponse?> extends RequestCache<T> {
@override @override
T onLocalData(String json) => _fromJson(jsonDecode(json) as Map<String, dynamic>); T onLocalData(String json) => _fromJson(jsonDecode(json) as Map<String, dynamic>);
} }
/// Captures the latest cache payload (cached or network) and rethrows the
/// captured network error if no payload arrived. Collapses the
/// `latest`/`capturedError`/`await ready` boilerplate that DataProviders
/// otherwise repeat per endpoint.
Future<T> resolveFromCache<T extends ApiResponse?>(
RequestCache<T> Function(void Function(T) onUpdate, void Function(Exception) onError) build, {
void Function(Object)? onError,
String? operationName,
}) async {
T? latest;
Object? capturedError;
final cache = build(
(data) => latest = data,
(e) {
capturedError = e;
onError?.call(e);
},
);
await cache.ready;
if (latest != null) return latest as T;
final err = capturedError;
if (err != null) throw err;
throw ParseException(
technicalDetails: operationName != null ? 'No data and no error from $operationName' : null,
);
}
+8 -10
View File
@@ -3,6 +3,7 @@ import 'package:flutter/material.dart';
import '../widget/debug/debug_tile.dart'; import '../widget/debug/debug_tile.dart';
import '../widget/debug/json_viewer.dart'; import '../widget/debug/json_viewer.dart';
import '../widget/info_dialog.dart';
import 'notification_tasks.dart'; import 'notification_tasks.dart';
class NotificationController { class NotificationController {
@@ -22,16 +23,13 @@ class NotificationController {
NotificationTasks.updateProviders(context); NotificationTasks.updateProviders(context);
DebugTile(context).run(() { DebugTile(context).run(() {
showDialog(context: context, builder: (context) => AlertDialog( InfoDialog.show(
title: const Text('Notification report'), context,
content: Column( 'Dieser Bericht wird angezeigt, da du den Entwicklermodus aktiviert hast und die App über eine Benachrichtigung geöffnet wurde.\n\n'
mainAxisSize: MainAxisSize.min, '${JsonViewer.format(message.data)}',
children: [ copyable: true,
const Text('Dieser Bericht wird angezeigt, da du den Entwicklermodus aktiviert hast und die App über eine Benachrichtigung geöffnet wurde.'), title: 'Notification report',
Text(JsonViewer.format(message.data)), );
],
),
));
}); });
} }
+13 -38
View File
@@ -25,22 +25,15 @@ import '../widget/debug/cache_view.dart';
import '../widget/file_viewer.dart'; import '../widget/file_viewer.dart';
import '../widget/user_avatar.dart'; import '../widget/user_avatar.dart';
/// Single entry point for full-page navigations across the app. /// Single entry point for full-page navigations. Dialogs and bottom sheets
/// /// stay at the call sites; only full-page pushes go through here.
/// Every full-page push in modules should go through one of these methods.
/// Dialogs (`showDialog`), bottom sheets (`showStickyFlexibleBottomSheet`,
/// `showDetailsBottomSheet`), and `Navigator.pop` for closing those
/// remain unchanged and live at the call sites.
class AppRoutes { class AppRoutes {
AppRoutes._(); AppRoutes._();
/// Token of a chat that should be auto-opened in the Talk tab once /// Set by [openChatByToken] (e.g. from a tapped notification) and consumed
/// the chat list view picks it up. Set by [openChatByToken] (e.g. from /// by `ChatList` once the matching room is loaded.
/// a tapped notification) and consumed by the `ChatList` widget.
static final ValueNotifier<String?> pendingChatToken = ValueNotifier(null); static final ValueNotifier<String?> pendingChatToken = ValueNotifier(null);
// -- Files --------------------------------------------------------------
static void openFolder(BuildContext context, List<String> path) { static void openFolder(BuildContext context, List<String> path) {
pushScreen(context, withNavBar: false, screen: Files(path: path)); pushScreen(context, withNavBar: false, screen: Files(path: path));
} }
@@ -53,14 +46,10 @@ class AppRoutes {
); );
} }
// -- Timetable ----------------------------------------------------------
static void openCustomEvents(BuildContext context) { static void openCustomEvents(BuildContext context) {
pushScreen(context, withNavBar: false, screen: const CustomEventsView()); pushScreen(context, withNavBar: false, screen: const CustomEventsView());
} }
// -- Marianum Message ---------------------------------------------------
static void openMarianumMessage(BuildContext context, String basePath, MarianumMessage message) { static void openMarianumMessage(BuildContext context, String basePath, MarianumMessage message) {
pushScreen( pushScreen(
context, context,
@@ -69,8 +58,6 @@ class AppRoutes {
); );
} }
// -- Sharing / Settings / Feedback / DevTools ---------------------------
static void openQrShare(BuildContext context) { static void openQrShare(BuildContext context) {
pushScreen(context, withNavBar: false, screen: const QrShareView()); pushScreen(context, withNavBar: false, screen: const QrShareView());
} }
@@ -95,8 +82,6 @@ class AppRoutes {
pushScreen(context, withNavBar: false, screen: const Roomplan()); pushScreen(context, withNavBar: false, screen: const Roomplan());
} }
// -- Talk ---------------------------------------------------------------
static void openMessageReactions(BuildContext context, String token, int messageId) { static void openMessageReactions(BuildContext context, String token, int messageId) {
pushScreen( pushScreen(
context, context,
@@ -105,8 +90,6 @@ class AppRoutes {
); );
} }
/// Opens a chat from a known [GetRoomResponseObject]. Delegates to
/// [TalkNavigator.pushSplitView] so tablet split-view behaviour stays intact.
static void openChatView( static void openChatView(
BuildContext context, { BuildContext context, {
required GetRoomResponseObject room, required GetRoomResponseObject room,
@@ -122,9 +105,8 @@ class AppRoutes {
context.read<ChatBloc>().setToken(room.token); context.read<ChatBloc>().setToken(room.token);
} }
/// Schedules a chat to be opened in the Talk tab. Use this when only the /// Schedules a chat to be opened in the Talk tab once the room is loaded.
/// token is known (e.g. from a tapped notification) — the actual push /// Use when only the token is known (e.g. from a tapped notification).
/// happens inside the `ChatList` widget once the room is available.
static void openChatByToken(BuildContext context, String token) { static void openChatByToken(BuildContext context, String token) {
pendingChatToken.value = token; pendingChatToken.value = token;
goToTalkTab(context); goToTalkTab(context);
@@ -135,11 +117,9 @@ class AppRoutes {
} }
} }
/// Resolves a pending chat token (set via [openChatByToken]) using the /// Resolves a pending chat token (set via [openChatByToken]). Returns null
/// [ChatListBloc]'s current rooms and the active [AccountData] credentials. /// if the room or account is not ready yet — callers should retry when
/// Returns `null` if the token cannot yet be matched (e.g. the room is /// [pendingChatToken] or the bloc state change.
/// still being loaded). Callers should keep listening to [pendingChatToken]
/// and the bloc state and retry when either changes.
static ResolvedPendingChat? resolvePendingChat(BuildContext context) { static ResolvedPendingChat? resolvePendingChat(BuildContext context) {
final token = pendingChatToken.value; final token = pendingChatToken.value;
if (token == null) return null; if (token == null) return null;
@@ -166,17 +146,14 @@ class AppRoutes {
return null; return null;
} }
// -- Module / Tab navigation ------------------------------------------- /// Pushes a module from the "Mehr" tab list. Modules already in the bottom
/// bar are switched to via [goToTab] instead.
/// Opens an [AppModule]'s root view as a full screen push (used by the
/// "Mehr" tab list). Modules that live in the bottom bar are reached via
/// [goToTab] instead.
static void openModule(BuildContext context, AppModule module) { static void openModule(BuildContext context, AppModule module) {
pushScreen(context, withNavBar: false, screen: module.create()); pushScreen(context, withNavBar: false, screen: module.create());
} }
/// Switches the bottom navigation to the given [module] if it is currently /// Switches the bottom navigation to [module]. Returns false when the
/// in the bottom bar. Returns `true` if the jump happened. /// module is not currently in the bottom bar.
static bool goToTab(BuildContext context, Modules module) { static bool goToTab(BuildContext context, Modules module) {
final index = AppModule.getBottomBarModules(context) final index = AppModule.getBottomBarModules(context)
.map((e) => e.module) .map((e) => e.module)
@@ -187,8 +164,6 @@ class AppRoutes {
return true; return true;
} }
/// Convenience wrapper for the Talk tab — preserved for the notification
/// handler API which only knows about Talk.
static void goToTalkTab(BuildContext context) { static void goToTalkTab(BuildContext context) {
goToTab(context, Modules.talk); goToTab(context, Modules.talk);
} }
@@ -12,21 +12,15 @@ abstract class DataLoader<TResult> {
} }
Future<TResult> run() async { Future<TResult> run() async {
var fetcher = fetch(); final response = await fetch();
await Future.wait([
fetcher,
Future.delayed(const Duration(milliseconds: 500)) // TODO tune or remove
]);
var response = await fetcher;
try { try {
return assemble(DataLoaderResult( return assemble(DataLoaderResult(
json: jsonDecode(response.data!), json: jsonDecode(response.data!),
headers: response.headers.map.map((key, value) => MapEntry(key, value.join(';'))), headers: response.headers.map.map((key, value) => MapEntry(key, value.join(';'))),
)); ));
} catch(trace, e) { } catch (e, stack) {
log(trace.toString()); log('DataLoader assemble failed', error: e, stackTrace: stack);
throw e; rethrow;
} }
} }
@@ -2,26 +2,22 @@ import '../../../../../api/marianumcloud/talk/create_room/create_room.dart';
import '../../../../../api/marianumcloud/talk/create_room/create_room_params.dart'; import '../../../../../api/marianumcloud/talk/create_room/create_room_params.dart';
import '../../../../../api/marianumcloud/talk/room/get_room_cache.dart'; import '../../../../../api/marianumcloud/talk/room/get_room_cache.dart';
import '../../../../../api/marianumcloud/talk/room/get_room_response.dart'; import '../../../../../api/marianumcloud/talk/room/get_room_response.dart';
import '../../../../../api/request_cache.dart';
class ChatListDataProvider { class ChatListDataProvider {
Future<GetRoomResponse> getRooms({ Future<GetRoomResponse> getRooms({
void Function(Object)? onError, void Function(Object)? onError,
bool renew = false, bool renew = false,
}) async { }) =>
GetRoomResponse? latest; resolveFromCache<GetRoomResponse>(
Object? capturedError; (onUpdate, onError) => GetRoomCache(
final cache = GetRoomCache(
renew: renew, renew: renew,
onUpdate: (data) => latest = data, onUpdate: onUpdate,
onError: (e) { onError: onError,
capturedError = e; ),
onError?.call(e); onError: onError,
}, operationName: 'getRooms',
); );
await cache.ready;
if (latest != null) return latest!;
throw capturedError ?? Exception('No data and no error from getRooms');
}
Future<void> createDirectRoom(String invite) => Future<void> createDirectRoom(String invite) =>
CreateRoom(CreateRoomParams(roomType: 1, invite: invite)).run(); CreateRoom(CreateRoomParams(roomType: 1, invite: invite)).run();
@@ -3,6 +3,7 @@ import 'package:nextcloud/nextcloud.dart';
import '../../../../../api/marianumcloud/webdav/queries/list_files/list_files_cache.dart'; import '../../../../../api/marianumcloud/webdav/queries/list_files/list_files_cache.dart';
import '../../../../../api/marianumcloud/webdav/queries/list_files/list_files_response.dart'; import '../../../../../api/marianumcloud/webdav/queries/list_files/list_files_response.dart';
import '../../../../../api/marianumcloud/webdav/webdav_api.dart'; import '../../../../../api/marianumcloud/webdav/webdav_api.dart';
import '../../../../../api/request_cache.dart';
class FilesDataProvider { class FilesDataProvider {
/// Lists files at [path]. Cached payload is delivered via [onCacheData] as /// Lists files at [path]. Cached payload is delivered via [onCacheData] as
@@ -14,22 +15,17 @@ class FilesDataProvider {
String path, { String path, {
void Function(ListFilesResponse)? onCacheData, void Function(ListFilesResponse)? onCacheData,
void Function(Object)? onError, void Function(Object)? onError,
}) async { }) =>
ListFilesResponse? latest; resolveFromCache<ListFilesResponse>(
Object? capturedError; (onUpdate, onError) => ListFilesCache(
final cache = ListFilesCache(
path: path, path: path,
onUpdate: (data) => latest = data, onUpdate: onUpdate,
onCacheData: onCacheData, onCacheData: onCacheData,
onError: (e) { onError: onError,
capturedError = e; ),
onError?.call(e); onError: onError,
}, operationName: 'listFiles',
); );
await cache.ready;
if (latest != null) return latest!;
throw capturedError ?? Exception('No data and no error from listFiles');
}
Future<void> createFolder(String fullPath) async { Future<void> createFolder(String fullPath) async {
final webdav = await WebdavApi.webdav; final webdav = await WebdavApi.webdav;
@@ -10,6 +10,7 @@ import '../../../../../api/mhsl/custom_timetable_event/remove/remove_custom_time
import '../../../../../api/mhsl/custom_timetable_event/remove/remove_custom_timetable_event_params.dart'; import '../../../../../api/mhsl/custom_timetable_event/remove/remove_custom_timetable_event_params.dart';
import '../../../../../api/mhsl/custom_timetable_event/update/update_custom_timetable_event.dart'; import '../../../../../api/mhsl/custom_timetable_event/update/update_custom_timetable_event.dart';
import '../../../../../api/mhsl/custom_timetable_event/update/update_custom_timetable_event_params.dart'; import '../../../../../api/mhsl/custom_timetable_event/update/update_custom_timetable_event_params.dart';
import '../../../../../api/request_cache.dart';
import '../../../../../api/webuntis/queries/get_holidays/get_holidays_cache.dart'; import '../../../../../api/webuntis/queries/get_holidays/get_holidays_cache.dart';
import '../../../../../api/webuntis/queries/get_holidays/get_holidays_response.dart'; import '../../../../../api/webuntis/queries/get_holidays/get_holidays_response.dart';
import '../../../../../api/webuntis/queries/get_rooms/get_rooms_cache.dart'; import '../../../../../api/webuntis/queries/get_rooms/get_rooms_cache.dart';
@@ -30,112 +31,84 @@ class TimetableDataProvider {
DateTime endDate, { DateTime endDate, {
void Function(Object)? onError, void Function(Object)? onError,
bool renew = false, bool renew = false,
}) async { }) =>
GetTimetableResponse? latest; resolveFromCache<GetTimetableResponse>(
Object? capturedError; (onUpdate, onError) => GetTimetableCache(
final cache = GetTimetableCache(
startdate: int.parse(_dateFormat.format(startDate)), startdate: int.parse(_dateFormat.format(startDate)),
enddate: int.parse(_dateFormat.format(endDate)), enddate: int.parse(_dateFormat.format(endDate)),
renew: renew, renew: renew,
onUpdate: (data) => latest = data, onUpdate: onUpdate,
onError: (e) { onError: onError,
capturedError = e; ),
onError?.call(e); onError: onError,
}, operationName: 'getWeek',
); );
await cache.ready;
if (latest != null) return latest!;
throw capturedError ?? Exception('No data and no error from getWeek');
}
Future<GetRoomsResponse> getRooms({ Future<GetRoomsResponse> getRooms({
void Function(Object)? onError, void Function(Object)? onError,
bool renew = false, bool renew = false,
}) async { }) =>
GetRoomsResponse? latest; resolveFromCache<GetRoomsResponse>(
Object? capturedError; (onUpdate, onError) => GetRoomsCache(
final cache = GetRoomsCache(
renew: renew, renew: renew,
onUpdate: (data) => latest = data, onUpdate: onUpdate,
onError: (e) { onError: onError,
capturedError = e; ),
onError?.call(e); onError: onError,
}, operationName: 'getRooms',
); );
await cache.ready;
if (latest != null) return latest!;
throw capturedError ?? Exception('No data and no error from getRooms');
}
Future<GetSubjectsResponse> getSubjects({ Future<GetSubjectsResponse> getSubjects({
void Function(Object)? onError, void Function(Object)? onError,
bool renew = false, bool renew = false,
}) async { }) =>
GetSubjectsResponse? latest; resolveFromCache<GetSubjectsResponse>(
Object? capturedError; (onUpdate, onError) => GetSubjectsCache(
final cache = GetSubjectsCache(
renew: renew, renew: renew,
onUpdate: (data) => latest = data, onUpdate: onUpdate,
onError: (e) { onError: onError,
capturedError = e; ),
onError?.call(e); onError: onError,
}, operationName: 'getSubjects',
); );
await cache.ready;
if (latest != null) return latest!;
throw capturedError ?? Exception('No data and no error from getSubjects');
}
Future<GetHolidaysResponse> getSchoolHolidays({ Future<GetHolidaysResponse> getSchoolHolidays({
void Function(Object)? onError, void Function(Object)? onError,
bool renew = false, bool renew = false,
}) async { }) =>
GetHolidaysResponse? latest; resolveFromCache<GetHolidaysResponse>(
Object? capturedError; (onUpdate, onError) => GetHolidaysCache(
final cache = GetHolidaysCache(
renew: renew, renew: renew,
onUpdate: (data) => latest = data, onUpdate: onUpdate,
onError: (e) { onError: onError,
capturedError = e; ),
onError?.call(e); onError: onError,
}, operationName: 'getSchoolHolidays',
); );
await cache.ready;
if (latest != null) return latest!;
throw capturedError ?? Exception('No data and no error from getSchoolHolidays');
}
Future<GetTimegridUnitsResponse> getTimegrid({bool renew = false}) async { Future<GetTimegridUnitsResponse> getTimegrid({bool renew = false}) =>
GetTimegridUnitsResponse? latest; resolveFromCache<GetTimegridUnitsResponse>(
Object? capturedError; (onUpdate, _) => GetTimegridUnitsCache(
final cache = GetTimegridUnitsCache(
renew: renew, renew: renew,
onUpdate: (data) => latest = data, onUpdate: onUpdate,
),
operationName: 'getTimegrid',
); );
await cache.ready;
if (latest != null) return latest!;
throw capturedError ?? Exception('No data and no error from getTimegrid');
}
Future<GetCustomTimetableEventResponse> getCustomEvents({ Future<GetCustomTimetableEventResponse> getCustomEvents({
bool renew = false, bool renew = false,
void Function(Object)? onError, void Function(Object)? onError,
}) async { }) =>
GetCustomTimetableEventResponse? latest; resolveFromCache<GetCustomTimetableEventResponse>(
Object? capturedError; (onUpdate, onError) => GetCustomTimetableEventCache(
final cache = GetCustomTimetableEventCache(
GetCustomTimetableEventParams(AccountData().getUserSecret()), GetCustomTimetableEventParams(AccountData().getUserSecret()),
renew: renew, renew: renew,
onUpdate: (data) => latest = data, onUpdate: onUpdate,
onError: (e) { onError: onError,
capturedError = e; ),
onError?.call(e); onError: onError,
}, operationName: 'getCustomEvents',
); );
await cache.ready;
if (latest != null) return latest!;
throw capturedError ?? Exception('No data and no error from getCustomEvents');
}
Future<void> addCustomEvent(CustomTimetableEvent event) => Future<void> addCustomEvent(CustomTimetableEvent event) =>
AddCustomTimetableEvent(AddCustomTimetableEventParams(AccountData().getUserSecret(), event)).run(); AddCustomTimetableEvent(AddCustomTimetableEventParams(AccountData().getUserSecret(), event)).run();
+1 -2
View File
@@ -1,8 +1,7 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter/services.dart'; import 'package:flutter/services.dart';
/// Copies [text] to the system clipboard and shows a SnackBar confirmation. /// Copies [text] to the system clipboard and shows a SnackBar.
/// Safe to await: respects context lifecycle via the provided [context].
Future<void> copyToClipboard( Future<void> copyToClipboard(
BuildContext context, BuildContext context,
String text, { String text, {
+7 -27
View File
@@ -7,6 +7,7 @@ import 'package:nextcloud/nextcloud.dart';
import '../../../api/marianumcloud/webdav/webdav_api.dart'; import '../../../api/marianumcloud/webdav/webdav_api.dart';
import '../../../widget/confirm_dialog.dart'; import '../../../widget/confirm_dialog.dart';
import '../../../widget/focus_behaviour.dart'; import '../../../widget/focus_behaviour.dart';
import '../../../widget/info_dialog.dart';
class FilesUploadDialog extends StatefulWidget { class FilesUploadDialog extends StatefulWidget {
final List<String> filePaths; final List<String> filePaths;
@@ -48,19 +49,11 @@ class _FilesUploadDialogState extends State<FilesUploadDialog> {
} }
void showHttpErrorCode(int httpErrorCode) { void showHttpErrorCode(int httpErrorCode) {
showDialog( InfoDialog.show(
context: context, context,
builder: (BuildContext context) => AlertDialog( 'Error code: $httpErrorCode',
title: const Text('Ein Fehler ist aufgetreten'), title: 'Ein Fehler ist aufgetreten',
contentPadding: const EdgeInsets.all(10), copyable: true,
content: Text('Error code: $httpErrorCode'),
actions: [
TextButton(
onPressed: () => Navigator.of(context).pop(),
child: const Text('Schließen', textAlign: TextAlign.center),
),
],
)
); );
} }
@@ -70,20 +63,7 @@ class _FilesUploadDialogState extends State<FilesUploadDialog> {
_overallProgressValue = 0.0; _overallProgressValue = 0.0;
_infoText = ''; _infoText = '';
}); });
showDialog( InfoDialog.show(context, message, title: 'Upload fehlgeschlagen', copyable: true);
context: context,
builder: (BuildContext context) => AlertDialog(
title: const Text('Upload fehlgeschlagen'),
contentPadding: const EdgeInsets.all(10),
content: Text(message),
actions: [
TextButton(
onPressed: () => Navigator.of(context).pop(),
child: const Text('Schließen', textAlign: TextAlign.center),
),
],
),
);
} }
Future<void> uploadFiles({bool override = false}) async { Future<void> uploadFiles({bool override = false}) async {
+10 -24
View File
@@ -11,6 +11,7 @@ import '../../../../utils/download_manager.dart';
import '../../../../utils/file_clipboard.dart'; import '../../../../utils/file_clipboard.dart';
import '../../../../widget/centered_leading.dart'; import '../../../../widget/centered_leading.dart';
import '../../../../widget/confirm_dialog.dart'; import '../../../../widget/confirm_dialog.dart';
import '../../../../widget/details_bottom_sheet.dart';
import '../../../../widget/info_dialog.dart'; import '../../../../widget/info_dialog.dart';
import 'file_details_sheet.dart'; import 'file_details_sheet.dart';
@@ -77,13 +78,7 @@ class _FileElementState extends State<FileElement> {
DownloadManager.instance.clear(widget.file.path); DownloadManager.instance.clear(widget.file.path);
_detachJob(); _detachJob();
setState(() {}); setState(() {});
showDialog<void>( InfoDialog.show(context, message, title: 'Download', copyable: true);
context: context,
builder: (context) => AlertDialog(
title: const Text('Download'),
content: Text(message),
),
);
} else if (status is DownloadCancelled) { } else if (status is DownloadCancelled) {
DownloadManager.instance.clear(widget.file.path); DownloadManager.instance.clear(widget.file.path);
_detachJob(); _detachJob();
@@ -172,6 +167,7 @@ class _FileElementState extends State<FileElement> {
Future<void> _rename() async { Future<void> _rename() async {
final controller = TextEditingController(text: widget.file.name); final controller = TextEditingController(text: widget.file.name);
try {
final newName = await showDialog<String>( final newName = await showDialog<String>(
context: context, context: context,
builder: (dialogCtx) => AlertDialog( builder: (dialogCtx) => AlertDialog(
@@ -198,6 +194,9 @@ class _FileElementState extends State<FileElement> {
final webdav = await WebdavApi.webdav; final webdav = await WebdavApi.webdav;
await webdav.move(PathUri.parse(widget.file.path), PathUri.parse(destination)); await webdav.move(PathUri.parse(widget.file.path), PathUri.parse(destination));
}, errorTitle: 'Umbenennen fehlgeschlagen'); }, errorTitle: 'Umbenennen fehlgeschlagen');
} finally {
controller.dispose();
}
} }
void _putOnClipboard({required bool copy}) { void _putOnClipboard({required bool copy}) {
@@ -234,25 +233,14 @@ class _FileElementState extends State<FileElement> {
widget.refetch(); widget.refetch();
} on Object catch (e) { } on Object catch (e) {
if (!mounted) return; if (!mounted) return;
await showDialog<void>( InfoDialog.show(context, e.toString(), title: errorTitle, copyable: true);
context: context,
builder: (dialogCtx) => AlertDialog(
title: Text(errorTitle),
content: Text(e.toString()),
actions: [TextButton(onPressed: () => Navigator.of(dialogCtx).pop(), child: const Text('OK'))],
),
);
} }
} }
void _showActionSheet() { void _showActionSheet() {
showModalBottomSheet<void>( showDetailsBottomSheet(
context: context, context,
showDragHandle: true, children: (sheetCtx) => [
builder: (sheetCtx) => SafeArea(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
ListTile( ListTile(
leading: const CenteredLeading(Icon(Icons.info_outline)), leading: const CenteredLeading(Icon(Icons.info_outline)),
title: const Text('Info'), title: const Text('Info'),
@@ -294,8 +282,6 @@ class _FileElementState extends State<FileElement> {
}, },
), ),
], ],
),
),
); );
} }
@@ -2,9 +2,6 @@ import 'package:flutter/material.dart';
import '../data/sort_options.dart'; import '../data/sort_options.dart';
/// AppBar action buttons for sort direction (asc/desc) and sort field
/// (name/date/size). Pure UI owners pass current values + selection
/// callbacks.
class FilesSortActions extends StatelessWidget { class FilesSortActions extends StatelessWidget {
final SortOption currentSort; final SortOption currentSort;
final bool ascending; final bool ascending;
+24 -21
View File
@@ -10,6 +10,8 @@ import '../../../state/app/modules/holidays/bloc/holidays_state.dart';
import '../../../widget/animated_time.dart'; import '../../../widget/animated_time.dart';
import '../../../widget/centered_leading.dart'; import '../../../widget/centered_leading.dart';
import '../../../widget/debug/debug_tile.dart'; import '../../../widget/debug/debug_tile.dart';
import '../../../widget/details_bottom_sheet.dart';
import '../../../widget/info_dialog.dart';
import '../../../widget/list_view_util.dart'; import '../../../widget/list_view_util.dart';
import '../../../widget/string_extensions.dart'; import '../../../widget/string_extensions.dart';
@@ -21,18 +23,13 @@ class HolidaysView extends StatelessWidget {
create: (context) => HolidaysBloc(), create: (context) => HolidaysBloc(),
autoRebuild: true, autoRebuild: true,
child: (context, bloc, state) { child: (context, bloc, state) {
void showDisclaimer() { void showDisclaimer() => InfoDialog.show(
showDialog(context: context, builder: (context) => AlertDialog( context,
title: const Text('Richtigkeit und Bereitstellung der Daten'),
content: const Text(''
'Sämtliche Datumsangaben sind ohne Gewähr.\n' 'Sämtliche Datumsangaben sind ohne Gewähr.\n'
'Ich übernehme weder Verantwortung für die Richtigkeit der Daten noch hafte ich für wirtschaftliche Schäden die aus der Verwendung dieser Daten entstehen können.\n\n' 'Ich übernehme weder Verantwortung für die Richtigkeit der Daten noch hafte ich für wirtschaftliche Schäden die aus der Verwendung dieser Daten entstehen können.\n\n'
'Die Daten stammen von https://ferien-api.de/'), 'Die Daten stammen von https://ferien-api.de/',
actions: [ title: 'Richtigkeit und Bereitstellung der Daten',
TextButton(child: const Text('Okay'), onPressed: () => Navigator.of(context).pop()), );
],
));
}
return Scaffold( return Scaffold(
appBar: AppBar( appBar: AppBar(
@@ -78,9 +75,16 @@ class HolidaysView extends StatelessWidget {
leading: const CenteredLeading(Icon(Icons.calendar_month)), leading: const CenteredLeading(Icon(Icons.calendar_month)),
title: Text('$holidayType ${getHolidayYear(holiday.start, holiday.end)}'), title: Text('$holidayType ${getHolidayYear(holiday.start, holiday.end)}'),
subtitle: Text('${formatDate(holiday.start)} - ${formatDate(holiday.end)}'), subtitle: Text('${formatDate(holiday.start)} - ${formatDate(holiday.end)}'),
onTap: () => showDialog(context: context, builder: (context) => SimpleDialog( onTap: () => showDetailsBottomSheet(
title: Text('$holidayType ${holiday.year} in Hessen'), context,
children: [ header: Padding(
padding: const EdgeInsets.fromLTRB(16, 4, 16, 12),
child: Text(
'$holidayType ${holiday.year} in Hessen',
style: Theme.of(context).textTheme.titleLarge,
),
),
children: (sheetCtx) => [
ListTile( ListTile(
leading: const CenteredLeading(Icon(Icons.signpost_outlined)), leading: const CenteredLeading(Icon(Icons.signpost_outlined)),
title: Text(holiday.name.capitalize()), title: Text(holiday.name.capitalize()),
@@ -94,21 +98,20 @@ class HolidaysView extends StatelessWidget {
leading: const Icon(Icons.date_range_outlined), leading: const Icon(Icons.date_range_outlined),
title: Text('bis zum ${formatDate(holiday.end)}'), title: Text('bis zum ${formatDate(holiday.end)}'),
), ),
Visibility( if (DateTime.parse(holiday.start).difference(DateTime.now()).isNegative)
visible: !DateTime.parse(holiday.start).difference(DateTime.now()).isNegative, ListTile(
replacement: ListTile(
leading: const CenteredLeading(Icon(Icons.content_paste_search_outlined)), leading: const CenteredLeading(Icon(Icons.content_paste_search_outlined)),
title: Text(Jiffy.parse(holiday.start).fromNow()), title: Text(Jiffy.parse(holiday.start).fromNow()),
), )
child: ListTile( else
ListTile(
leading: const CenteredLeading(Icon(Icons.timer_outlined)), leading: const CenteredLeading(Icon(Icons.timer_outlined)),
title: AnimatedTime(callback: () => DateTime.parse(holiday.start).difference(DateTime.now())), title: AnimatedTime(callback: () => DateTime.parse(holiday.start).difference(DateTime.now())),
subtitle: Text(Jiffy.parse(holiday.start).fromNow()), subtitle: Text(Jiffy.parse(holiday.start).fromNow()),
), ),
), DebugTile(sheetCtx).jsonData(holiday.toJson()),
DebugTile(context).jsonData(holiday.toJson()),
], ],
)), ),
trailing: const Icon(Icons.arrow_right), trailing: const Icon(Icons.arrow_right),
); );
}), }),
@@ -5,26 +5,33 @@ import '../../../../widget/share_position_origin.dart';
enum ShareTargetType { qr } enum ShareTargetType { qr }
class SelectShareTypeDialog extends StatelessWidget { /// Bottom sheet that lets the user pick how they want to share the app.
const SelectShareTypeDialog({super.key}); /// Resolves with [ShareTargetType.qr] for the QR option, or `null` when the
/// sheet is dismissed (link sharing fires immediately and resolves null).
@override Future<ShareTargetType?> showSelectShareTypeSheet(BuildContext context) {
Widget build(BuildContext context) => SimpleDialog( return showModalBottomSheet<ShareTargetType>(
context: context,
isScrollControlled: true,
showDragHandle: true,
useSafeArea: true,
builder: (sheetCtx) => SafeArea(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [ children: [
ListTile( ListTile(
leading: const Icon(Icons.qr_code_2_outlined), leading: const Icon(Icons.qr_code_2_outlined),
title: const Text('Per QR-Code'), title: const Text('Per QR-Code'),
trailing: const Icon(Icons.arrow_right), trailing: const Icon(Icons.arrow_right),
onTap: () => Navigator.of(context).pop(ShareTargetType.qr), onTap: () => Navigator.of(sheetCtx).pop(ShareTargetType.qr),
), ),
ListTile( ListTile(
leading: const Icon(Icons.link_outlined), leading: const Icon(Icons.link_outlined),
title: const Text('Per Link teilen'), title: const Text('Per Link teilen'),
trailing: const Icon(Icons.arrow_right), trailing: const Icon(Icons.arrow_right),
onTap: () { onTap: () {
Navigator.of(context).pop(); Navigator.of(sheetCtx).pop();
SharePlus.instance.share(ShareParams( SharePlus.instance.share(ShareParams(
sharePositionOrigin: SharePositionOrigin.get(context), sharePositionOrigin: SharePositionOrigin.get(sheetCtx),
subject: 'App Teilen', subject: 'App Teilen',
text: 'Hol dir die für das Marianum maßgeschneiderte App:' text: 'Hol dir die für das Marianum maßgeschneiderte App:'
'\n\nAndroid: https://play.google.com/store/apps/details?id=eu.mhsl.marianum.mobile.client ' '\n\nAndroid: https://play.google.com/store/apps/details?id=eu.mhsl.marianum.mobile.client '
@@ -32,7 +39,9 @@ class SelectShareTypeDialog extends StatelessWidget {
'\n\nViel Spaß!', '\n\nViel Spaß!',
)); ));
}, },
) ),
], ],
),
),
); );
} }
+1 -4
View File
@@ -42,10 +42,7 @@ class _OverhangState extends State<Overhang> {
subtitle: const Text('Mit Freunden und deiner Klasse teilen'), subtitle: const Text('Mit Freunden und deiner Klasse teilen'),
trailing: const Icon(Icons.arrow_right), trailing: const Icon(Icons.arrow_right),
onTap: () async { onTap: () async {
final result = await showDialog<ShareTargetType>( final result = await showSelectShareTypeSheet(context);
context: context,
builder: (_) => const SelectShareTypeDialog(),
);
if (!mounted || result != ShareTargetType.qr) return; if (!mounted || result != ShareTargetType.qr) return;
if (context.mounted) AppRoutes.openQrShare(context); if (context.mounted) AppRoutes.openQrShare(context);
}, },
@@ -7,6 +7,7 @@ import 'package:package_info_plus/package_info_plus.dart';
import '../../../../state/app/modules/settings/bloc/settings_cubit.dart'; import '../../../../state/app/modules/settings/bloc/settings_cubit.dart';
import '../../../../widget/centered_leading.dart'; import '../../../../widget/centered_leading.dart';
import '../../../../widget/confirm_dialog.dart'; import '../../../../widget/confirm_dialog.dart';
import '../../../../widget/details_bottom_sheet.dart';
import '../data/default_settings.dart'; import '../data/default_settings.dart';
import '../widgets/privacy_info.dart'; import '../widgets/privacy_info.dart';
import 'dev_tools_section.dart'; import 'dev_tools_section.dart';
@@ -69,10 +70,9 @@ class AboutSection extends StatelessWidget {
); );
} }
void _showPrivacyDialog(BuildContext context) => showDialog( void _showPrivacyDialog(BuildContext context) => showDetailsBottomSheet(
context: context, context,
builder: (context) => SimpleDialog( children: (sheetCtx) => [
children: [
ListTile( ListTile(
leading: const CenteredLeading(Icon(Icons.school_outlined)), leading: const CenteredLeading(Icon(Icons.school_outlined)),
title: const Text('Infos zum Marianum Fulda'), title: const Text('Infos zum Marianum Fulda'),
@@ -82,7 +82,7 @@ class AboutSection extends StatelessWidget {
providerText: 'Marianum', providerText: 'Marianum',
imprintUrl: 'https://www.marianum-fulda.de/impressum', imprintUrl: 'https://www.marianum-fulda.de/impressum',
privacyUrl: 'https://www.marianum-fulda.de/datenschutz', privacyUrl: 'https://www.marianum-fulda.de/datenschutz',
).showPopup(context), ).showPopup(sheetCtx),
), ),
ListTile( ListTile(
leading: const CenteredLeading(Icon(Icons.date_range_outlined)), leading: const CenteredLeading(Icon(Icons.date_range_outlined)),
@@ -93,7 +93,7 @@ class AboutSection extends StatelessWidget {
providerText: 'Untis', providerText: 'Untis',
imprintUrl: 'https://www.untis.at/impressum', imprintUrl: 'https://www.untis.at/impressum',
privacyUrl: 'https://www.untis.at/datenschutz-wu-apps', privacyUrl: 'https://www.untis.at/datenschutz-wu-apps',
).showPopup(context), ).showPopup(sheetCtx),
), ),
ListTile( ListTile(
leading: const CenteredLeading(Icon(Icons.send_time_extension_outlined)), leading: const CenteredLeading(Icon(Icons.send_time_extension_outlined)),
@@ -104,10 +104,9 @@ class AboutSection extends StatelessWidget {
providerText: 'mhsl', providerText: 'mhsl',
imprintUrl: 'https://mhsl.eu/id.html', imprintUrl: 'https://mhsl.eu/id.html',
privacyUrl: 'https://mhsl.eu/datenschutz.html', privacyUrl: 'https://mhsl.eu/datenschutz.html',
).showPopup(context), ).showPopup(sheetCtx),
), ),
], ],
),
); );
void _toggleDeveloperMode(BuildContext context, SettingsCubit settings, bool? state) { void _toggleDeveloperMode(BuildContext context, SettingsCubit settings, bool? state) {
@@ -11,6 +11,7 @@ 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';
import '../../../../widget/debug/json_viewer.dart'; import '../../../../widget/debug/json_viewer.dart';
import '../../../../widget/details_bottom_sheet.dart';
class DevToolsSection extends StatefulWidget { class DevToolsSection extends StatefulWidget {
final SettingsCubit settings; final SettingsCubit settings;
@@ -29,13 +30,15 @@ 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( showDetailsBottomSheet(
context: context, context,
builder: (dialogCtx) => BlocBuilder<SettingsCubit, model.Settings>( children: (sheetCtx) => [
BlocBuilder<SettingsCubit, model.Settings>(
bloc: widget.settings, bloc: widget.settings,
builder: (_, _) { builder: (_, _) {
final dev = widget.settings.val().devToolsSettings; final dev = widget.settings.val().devToolsSettings;
return SimpleDialog( return Column(
mainAxisSize: MainAxisSize.min,
children: [ children: [
ListTile( ListTile(
leading: const Icon(Icons.auto_graph_outlined), leading: const Icon(Icons.auto_graph_outlined),
@@ -65,6 +68,7 @@ class _DevToolsSectionState extends State<DevToolsSection> {
); );
}, },
), ),
],
); );
}, },
), ),
@@ -122,9 +126,6 @@ class _DevToolsSectionState extends State<DevToolsSection> {
leading: const CenteredLeading(Icon(Icons.data_object)), leading: const CenteredLeading(Icon(Icons.data_object)),
title: const Text('BLOC-storage state cache'), title: const Text('BLOC-storage state cache'),
subtitle: const Text('Lange tippen um zu löschen'), subtitle: const Text('Lange tippen um zu löschen'),
onTap: () {
// Navigator.push(context, MaterialPageRoute(builder: (context) => const CacheView()));
},
onLongPress: () { onLongPress: () {
ConfirmDialog( ConfirmDialog(
title: 'BLOC-Cache löschen', title: 'BLOC-Cache löschen',
@@ -2,6 +2,7 @@ import 'package:flutter/material.dart';
import '../../../../widget/centered_leading.dart'; import '../../../../widget/centered_leading.dart';
import '../../../../widget/confirm_dialog.dart'; import '../../../../widget/confirm_dialog.dart';
import '../../../../widget/details_bottom_sheet.dart';
class PrivacyInfo { class PrivacyInfo {
String providerText; String providerText;
@@ -11,22 +12,29 @@ class PrivacyInfo {
PrivacyInfo({required this.providerText, required this.imprintUrl, required this.privacyUrl}); PrivacyInfo({required this.providerText, required this.imprintUrl, required this.privacyUrl});
void showPopup(BuildContext context) { void showPopup(BuildContext context) {
showDialog(context: context, builder: (context) => SimpleDialog( showDetailsBottomSheet(
title: Text('Betreiberinformation | $providerText'), context,
children: [ header: Padding(
padding: const EdgeInsets.fromLTRB(16, 4, 16, 12),
child: Text(
'Betreiberinformation | $providerText',
style: Theme.of(context).textTheme.titleLarge,
),
),
children: (sheetCtx) => [
ListTile( ListTile(
leading: const CenteredLeading(Icon(Icons.person_pin_outlined)), leading: const CenteredLeading(Icon(Icons.person_pin_outlined)),
title: const Text('Impressum'), title: const Text('Impressum'),
subtitle: Text(imprintUrl), subtitle: Text(imprintUrl),
onTap: () => ConfirmDialog.openBrowser(context, imprintUrl), onTap: () => ConfirmDialog.openBrowser(sheetCtx, imprintUrl),
), ),
ListTile( ListTile(
leading: const CenteredLeading(Icon(Icons.privacy_tip_outlined)), leading: const CenteredLeading(Icon(Icons.privacy_tip_outlined)),
title: const Text('Datenschutzerklärung'), title: const Text('Datenschutzerklärung'),
subtitle: Text(privacyUrl), subtitle: Text(privacyUrl),
onTap: () => ConfirmDialog.openBrowser(context, privacyUrl), onTap: () => ConfirmDialog.openBrowser(sheetCtx, privacyUrl),
), ),
], ],
)); );
} }
} }
+4 -5
View File
@@ -12,6 +12,7 @@ import '../../../state/app/modules/chat_list/bloc/chat_list_bloc.dart';
import '../../../state/app/modules/chat_list/bloc/chat_list_state.dart'; import '../../../state/app/modules/chat_list/bloc/chat_list_state.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/info_dialog.dart';
import '../../../widget/placeholder_view.dart'; import '../../../widget/placeholder_view.dart';
import 'join_chat.dart'; import 'join_chat.dart';
import 'search_chat.dart'; import 'search_chat.dart';
@@ -98,11 +99,9 @@ class _ChatListViewState extends State<_ChatListView> {
NotifyUpdater.enableAfterDisclaimer(_settings).asDialog(context); NotifyUpdater.enableAfterDisclaimer(_settings).asDialog(context);
break; break;
case AuthorizationStatus.denied: case AuthorizationStatus.denied:
showDialog( InfoDialog.show(
context: context, context,
builder: (_) => const AlertDialog( 'Du kannst die Benachrichtigungen später jederzeit in den App-Einstellungen aktivieren.',
content: Text('Du kannst die Benachrichtigungen später jederzeit in den App-Einstellungen aktivieren.'),
),
); );
break; break;
default: default:
+2 -3
View File
@@ -40,9 +40,8 @@ class BubbleStyle {
final double borderRadius; final double borderRadius;
} }
/// Lightweight chat bubble. Replaces the abandoned `bubble` package: renders a /// The "nip" is faked by flattening one corner so the bubble anchors to
/// rounded container with optional shadow / border. The nip is conveyed by /// the speaker side.
/// flattening one corner so the bubble visually anchors to the speaker side.
class Bubble extends StatelessWidget { class Bubble extends StatelessWidget {
const Bubble({required this.child, required this.style, super.key}); const Bubble({required this.child, required this.style, super.key});
@@ -246,9 +246,6 @@ class _ChatBubbleState extends State<ChatBubble> with SingleTickerProviderStateM
} }
} }
/// Stack inside the bubble: actor name (top-left, optional), message body
/// (centre), timestamp + read marker (bottom-right, optional), and a
/// download progress bar overlaid at the bottom while a job is running.
class _BubbleContent extends StatelessWidget { class _BubbleContent extends StatelessWidget {
final Text actorText; final Text actorText;
final Text timeText; final Text timeText;
@@ -14,13 +14,14 @@ import '../../../../utils/clipboard_helper.dart';
import '../../../../widget/app_progress_indicator.dart'; import '../../../../widget/app_progress_indicator.dart';
import '../../../../widget/async_action_button.dart'; import '../../../../widget/async_action_button.dart';
import '../../../../widget/debug/debug_tile.dart'; import '../../../../widget/debug/debug_tile.dart';
import '../../../../widget/details_bottom_sheet.dart';
const _commonReactions = <String>['👍', '👎', '😆', '❤️', '👀']; const _commonReactions = <String>['👍', '👎', '😆', '❤️', '👀'];
/// Long-press / double-tap options dialog for a single chat message bubble. /// Long-press / double-tap options dialog for a single chat message bubble.
/// The hosting [ChatBubble] keeps responsibility for rendering the bubble; /// The hosting [ChatBubble] keeps responsibility for rendering the bubble;
/// this file owns the modal interactions (react, reply, copy, delete, ...). /// this file owns the modal interactions (react, reply, copy, delete, ...).
Future<void> showChatMessageOptionsDialog( void showChatMessageOptionsDialog(
BuildContext context, { BuildContext context, {
required GetRoomResponseObject chatData, required GetRoomResponseObject chatData,
required GetChatResponseObject bubbleData, required GetChatResponseObject bubbleData,
@@ -34,24 +35,23 @@ Future<void> showChatMessageOptionsDialog(
.add(const Duration(hours: 6)) .add(const Duration(hours: 6))
.isAfter(DateTime.now()); .isAfter(DateTime.now());
return showDialog( showDetailsBottomSheet(
context: context, context,
builder: (dialogCtx) => SimpleDialog( children: (sheetCtx) => [
children: [
if (canReact) if (canReact)
_ReactionsRow( _ReactionsRow(
chatToken: chatData.token, chatToken: chatData.token,
messageId: bubbleData.id, messageId: bubbleData.id,
onRefetch: onRefetch, onRefetch: onRefetch,
dialogContext: dialogCtx, sheetContext: sheetCtx,
), ),
if (bubbleData.isReplyable) if (bubbleData.isReplyable)
ListTile( ListTile(
leading: const Icon(Icons.reply_outlined), leading: const Icon(Icons.reply_outlined),
title: const Text('Antworten'), title: const Text('Antworten'),
onTap: () { onTap: () {
dialogCtx.read<ChatBloc>().setReferenceMessageId(bubbleData.id); sheetCtx.read<ChatBloc>().setReferenceMessageId(bubbleData.id);
Navigator.of(dialogCtx).pop(); Navigator.of(sheetCtx).pop();
}, },
), ),
if (canReact) if (canReact)
@@ -59,7 +59,7 @@ Future<void> showChatMessageOptionsDialog(
leading: const Icon(Icons.emoji_emotions_outlined), leading: const Icon(Icons.emoji_emotions_outlined),
title: const Text('Reaktionen'), title: const Text('Reaktionen'),
onTap: () { onTap: () {
Navigator.of(dialogCtx).pop(); Navigator.of(sheetCtx).pop();
if (!parentContext.mounted) return; if (!parentContext.mounted) return;
AppRoutes.openMessageReactions(parentContext, chatData.token, bubbleData.id); AppRoutes.openMessageReactions(parentContext, chatData.token, bubbleData.id);
}, },
@@ -70,14 +70,14 @@ Future<void> showChatMessageOptionsDialog(
title: const Text('Nachricht kopieren'), title: const Text('Nachricht kopieren'),
onTap: () { onTap: () {
copyToClipboard(parentContext, bubbleData.message); copyToClipboard(parentContext, bubbleData.message);
Navigator.of(dialogCtx).pop(); Navigator.of(sheetCtx).pop();
}, },
), ),
if (!kReleaseMode && !isSender && chatData.type != GetRoomResponseObjectConversationType.oneToOne) if (!kReleaseMode && !isSender && chatData.type != GetRoomResponseObjectConversationType.oneToOne)
ListTile( ListTile(
leading: const Icon(Icons.sms_outlined), leading: const Icon(Icons.sms_outlined),
title: Text("Private Nachricht an '${bubbleData.actorDisplayName}'"), title: Text("Private Nachricht an '${bubbleData.actorDisplayName}'"),
onTap: () => Navigator.of(dialogCtx).pop(), onTap: () => Navigator.of(sheetCtx).pop(),
), ),
if (canDelete) if (canDelete)
AsyncListTile( AsyncListTile(
@@ -85,12 +85,11 @@ Future<void> showChatMessageOptionsDialog(
title: const Text('Nachricht löschen'), title: const Text('Nachricht löschen'),
onPressed: () async { onPressed: () async {
await DeleteMessage(chatData.token, bubbleData.id).run(); await DeleteMessage(chatData.token, bubbleData.id).run();
if (dialogCtx.mounted) dialogCtx.read<ChatBloc>().refresh(); if (sheetCtx.mounted) sheetCtx.read<ChatBloc>().refresh();
}, },
), ),
DebugTile(dialogCtx).jsonData(bubbleData.toJson()), DebugTile(sheetCtx).jsonData(bubbleData.toJson()),
], ],
),
); );
} }
@@ -98,13 +97,13 @@ 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;
final BuildContext dialogContext; final BuildContext sheetContext;
const _ReactionsRow({ const _ReactionsRow({
required this.chatToken, required this.chatToken,
required this.messageId, required this.messageId,
required this.onRefetch, required this.onRefetch,
required this.dialogContext, required this.sheetContext,
}); });
@override @override
@@ -131,7 +130,7 @@ class _ReactionsRowState extends State<_ReactionsRow> {
if (!mounted) return; if (!mounted) return;
if (ok) { if (ok) {
widget.onRefetch(renew: true); widget.onRefetch(renew: true);
if (widget.dialogContext.mounted) Navigator.of(widget.dialogContext).pop(); if (widget.sheetContext.mounted) Navigator.of(widget.sheetContext).pop();
} }
} }
+7 -5
View File
@@ -15,6 +15,7 @@ import '../../../../state/app/modules/chat_list/bloc/chat_list_bloc.dart';
import '../../../../widget/async_action_button.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/details_bottom_sheet.dart';
import '../../../../widget/user_avatar.dart'; import '../../../../widget/user_avatar.dart';
import '../chat_view.dart'; import '../chat_view.dart';
import '../talk_navigator.dart'; import '../talk_navigator.dart';
@@ -124,8 +125,9 @@ class _ChatTileState extends State<ChatTile> {
}, },
onLongPress: () { onLongPress: () {
if (widget.disableContextActions) return; if (widget.disableContextActions) return;
showDialog(context: context, builder: (dialogCtx) => SimpleDialog( showDetailsBottomSheet(
children: [ context,
children: (sheetCtx) => [
if (widget.data.unreadMessages > 0) if (widget.data.unreadMessages > 0)
AsyncListTile( AsyncListTile(
leading: const Icon(Icons.mark_chat_read_outlined), leading: const Icon(Icons.mark_chat_read_outlined),
@@ -163,7 +165,7 @@ class _ChatTileState extends State<ChatTile> {
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(); Navigator.of(sheetCtx).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.',
@@ -175,9 +177,9 @@ class _ChatTileState extends State<ChatTile> {
).asDialog(context); ).asDialog(context);
}, },
), ),
DebugTile(dialogCtx).jsonData(widget.data.toJson()), DebugTile(sheetCtx).jsonData(widget.data.toJson()),
], ],
)); );
}, },
); );
} }
@@ -29,7 +29,6 @@ class _PollOptionsListState extends State<PollOptionsList> {
final portion = numVoters == 0 ? 0.0 : (votes / numVoters); final portion = numVoters == 0 ? 0.0 : (votes / numVoters);
return ListTile( return ListTile(
// enabled: false,
isThreeLine: portionsVisible, isThreeLine: portionsVisible,
dense: true, dense: true,
title: Text( title: Text(
@@ -3,14 +3,22 @@ const double kCalendarEndHour = 17.25;
const Duration kCalendarTimeInterval = Duration(minutes: 30); const Duration kCalendarTimeInterval = Duration(minutes: 30);
const double kCalendarViewHeaderHeight = 60; const double kCalendarViewHeaderHeight = 60;
/// Minimum pixels per hour. Below this, the grid scrolls vertically rather /// Below this, the grid scrolls vertically rather than compressing further.
/// than compressing further.
const double kCalendarMinPxPerHour = 56; const double kCalendarMinPxPerHour = 56;
/// Minimum height of a lesson block in the period-based layout. The grid /// The grid scrolls vertically once lessons would otherwise be smaller.
/// scrolls vertically once lessons would otherwise be smaller than this.
const double kLessonBlockMinHeight = 50; const double kLessonBlockMinHeight = 50;
/// Fixed height of a break block in the period-based layout. Independent of /// Fixed (independent of actual break duration); breaks render as a compact
/// the actual break duration; breaks are rendered as a compact indicator. /// indicator.
const double kBreakBlockHeight = 28; const double kBreakBlockHeight = 28;
const int kOutsideChipsMaxVisible = 2;
const double kOutsideChipHeight = 22;
const double kOutsideChipSpacing = 3;
const double kOutsideStripVerticalPadding = 3;
const double kAppointmentTitleFontSize = 15;
const double kAppointmentTitleMinFontSize = 11;
const double kAppointmentBodyFontSize = 10;
const double kAppointmentBodyLineHeight = 1.15;
@@ -2,14 +2,11 @@ import 'package:flutter/material.dart';
import 'package:syncfusion_flutter_calendar/calendar.dart'; import 'package:syncfusion_flutter_calendar/calendar.dart';
import '../data/arbitrary_appointment.dart'; import '../data/arbitrary_appointment.dart';
import '../data/calendar_layout.dart';
import 'cross_painter.dart'; import 'cross_painter.dart';
class AppointmentTile extends StatelessWidget { class AppointmentTile extends StatelessWidget {
static const _radius = BorderRadius.all(Radius.circular(7)); static const _radius = BorderRadius.all(Radius.circular(7));
static const _titleFontSize = 15.0;
static const _titleMinFontSize = 11.0;
static const _bodyFontSize = 10.0;
static const _bodyLineHeight = 1.15;
final Appointment appointment; final Appointment appointment;
final bool crossedOut; final bool crossedOut;
@@ -42,8 +39,8 @@ class AppointmentTile extends StatelessWidget {
children: [ children: [
_AdaptiveTitle( _AdaptiveTitle(
text: appointment.subject, text: appointment.subject,
fontSize: _titleFontSize, fontSize: kAppointmentTitleFontSize,
minFontSize: _titleMinFontSize, minFontSize: kAppointmentTitleMinFontSize,
fontWeight: FontWeight.w500, fontWeight: FontWeight.w500,
), ),
if (isCustom) ...[ if (isCustom) ...[
@@ -53,8 +50,8 @@ class AppointmentTile extends StatelessWidget {
padding: const EdgeInsets.only(top: 1), padding: const EdgeInsets.only(top: 1),
child: _WrappingBody( child: _WrappingBody(
text: description, text: description,
fontSize: _bodyFontSize, fontSize: kAppointmentBodyFontSize,
lineHeight: _bodyLineHeight, lineHeight: kAppointmentBodyLineHeight,
), ),
), ),
), ),
@@ -63,7 +60,7 @@ class AppointmentTile extends StatelessWidget {
.split('\n') .split('\n')
.where((p) => p.isNotEmpty) .where((p) => p.isNotEmpty)
.take(2)) .take(2))
_ScaledLine(text: line, fontSize: _bodyFontSize), _ScaledLine(text: line, fontSize: kAppointmentBodyFontSize),
], ],
], ],
), ),
@@ -1,11 +1,6 @@
part of '../custom_workweek_calendar.dart'; part of '../custom_workweek_calendar.dart';
class _OutsideHoursStrip extends StatelessWidget { class _OutsideHoursStrip extends StatelessWidget {
static const int _maxVisibleChips = 2;
static const double _chipHeight = 22;
static const double _chipSpacing = 3;
static const double _verticalPadding = 3;
final DateTime weekStart; final DateTime weekStart;
final List<Appointment> appointments; final List<Appointment> appointments;
final double rulerWidth; final double rulerWidth;
@@ -28,17 +23,17 @@ class _OutsideHoursStrip extends StatelessWidget {
final theme = Theme.of(context); final theme = Theme.of(context);
final maxChipsPerDay = outside final maxChipsPerDay = outside
.map((day) => day.length > _maxVisibleChips ? _maxVisibleChips : day.length) .map((day) => day.length > kOutsideChipsMaxVisible ? kOutsideChipsMaxVisible : day.length)
.fold<int>(0, (m, c) => c > m ? c : m); .fold<int>(0, (m, c) => c > m ? c : m);
final stripHeight = _verticalPadding * 2 + final stripHeight = kOutsideStripVerticalPadding * 2 +
maxChipsPerDay * _chipHeight + maxChipsPerDay * kOutsideChipHeight +
(maxChipsPerDay > 1 ? (maxChipsPerDay - 1) * _chipSpacing : 0); (maxChipsPerDay > 1 ? (maxChipsPerDay - 1) * kOutsideChipSpacing : 0);
return Container( return Container(
color: theme.colorScheme.surfaceContainerLowest, color: theme.colorScheme.surfaceContainerLowest,
padding: const EdgeInsets.symmetric(vertical: _verticalPadding), padding: const EdgeInsets.symmetric(vertical: kOutsideStripVerticalPadding),
child: SizedBox( child: SizedBox(
height: stripHeight - _verticalPadding * 2, height: stripHeight - kOutsideStripVerticalPadding * 2,
child: Row( child: Row(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
@@ -47,9 +42,6 @@ class _OutsideHoursStrip extends StatelessWidget {
Expanded( Expanded(
child: _OutsideDayColumn( child: _OutsideDayColumn(
appointments: outside[d], appointments: outside[d],
maxVisible: _maxVisibleChips,
chipHeight: _chipHeight,
chipSpacing: _chipSpacing,
onAppointmentTap: onAppointmentTap, onAppointmentTap: onAppointmentTap,
isCrossedOut: isCrossedOut, isCrossedOut: isCrossedOut,
), ),
@@ -63,17 +55,11 @@ class _OutsideHoursStrip extends StatelessWidget {
class _OutsideDayColumn extends StatelessWidget { class _OutsideDayColumn extends StatelessWidget {
final List<Appointment> appointments; final List<Appointment> appointments;
final int maxVisible;
final double chipHeight;
final double chipSpacing;
final void Function(Appointment) onAppointmentTap; final void Function(Appointment) onAppointmentTap;
final bool Function(Appointment) isCrossedOut; final bool Function(Appointment) isCrossedOut;
const _OutsideDayColumn({ const _OutsideDayColumn({
required this.appointments, required this.appointments,
required this.maxVisible,
required this.chipHeight,
required this.chipSpacing,
required this.onAppointmentTap, required this.onAppointmentTap,
required this.isCrossedOut, required this.isCrossedOut,
}); });
@@ -132,11 +118,12 @@ class _OutsideDayColumn extends StatelessWidget {
if (!aLike && bLike) return 1; if (!aLike && bLike) return 1;
return a.startTime.compareTo(b.startTime); return a.startTime.compareTo(b.startTime);
}); });
final visible = sorted.length <= maxVisible final visible = sorted.length <= kOutsideChipsMaxVisible
? sorted ? sorted
: sorted.take(maxVisible - 1).toList(); : sorted.take(kOutsideChipsMaxVisible - 1).toList();
final overflow = final overflow = sorted.length <= kOutsideChipsMaxVisible
sorted.length <= maxVisible ? const <Appointment>[] : sorted.skip(maxVisible - 1).toList(); ? const <Appointment>[]
: sorted.skip(kOutsideChipsMaxVisible - 1).toList();
return Padding( return Padding(
padding: const EdgeInsets.symmetric(horizontal: 2), padding: const EdgeInsets.symmetric(horizontal: 2),
@@ -145,9 +132,9 @@ class _OutsideDayColumn extends StatelessWidget {
crossAxisAlignment: CrossAxisAlignment.stretch, crossAxisAlignment: CrossAxisAlignment.stretch,
children: [ children: [
for (var i = 0; i < visible.length; i++) ...[ for (var i = 0; i < visible.length; i++) ...[
if (i > 0) SizedBox(height: chipSpacing), if (i > 0) const SizedBox(height: kOutsideChipSpacing),
SizedBox( SizedBox(
height: chipHeight, height: kOutsideChipHeight,
child: _OutsideChip( child: _OutsideChip(
appointment: visible[i], appointment: visible[i],
onTap: () => onAppointmentTap(visible[i]), onTap: () => onAppointmentTap(visible[i]),
@@ -155,9 +142,9 @@ class _OutsideDayColumn extends StatelessWidget {
), ),
], ],
if (overflow.isNotEmpty) ...[ if (overflow.isNotEmpty) ...[
SizedBox(height: chipSpacing), const SizedBox(height: kOutsideChipSpacing),
SizedBox( SizedBox(
height: chipHeight, height: kOutsideChipHeight,
child: _OutsideOverflowChip( child: _OutsideOverflowChip(
count: overflow.length, count: overflow.length,
onTap: () => _showOverflow(context, overflow), onTap: () => _showOverflow(context, overflow),
@@ -429,8 +429,7 @@ class _OverflowTile extends StatelessWidget {
padding: const EdgeInsets.all(1), padding: const EdgeInsets.all(1),
child: Stack( child: Stack(
children: [ children: [
// Card peeking out at the bottom — visual hint that more cards lie // Stacked-cards effect: a darker layer peeks out below the front card.
// underneath the visible one.
Positioned( Positioned(
top: 4, top: 4,
left: 2, left: 2,
@@ -443,7 +442,6 @@ class _OverflowTile extends StatelessWidget {
), ),
), ),
), ),
// Front card with the "+N" indicator.
Positioned( Positioned(
top: 0, top: 0,
left: 0, left: 0,
@@ -40,10 +40,9 @@ class AsyncTextButton extends StatelessWidget {
], ],
) )
: child; : child;
return _InlineErrorWrapper( final button = TextButton(onPressed: handler, child: content);
controller: controller, if (!showInlineError) return button;
child: TextButton(onPressed: handler, child: content), return _InlineErrorWrapper(controller: controller, child: button);
);
}, },
); );
} }
+8 -4
View File
@@ -81,7 +81,7 @@ class _DeferredPdfViewerState extends State<_DeferredPdfViewer> {
} }
class _FileViewerState extends State<FileViewer> { class _FileViewerState extends State<FileViewer> {
PhotoViewController photoViewController = PhotoViewController(); final PhotoViewController photoViewController = PhotoViewController();
late SettingsCubit settings = context.read<SettingsCubit>(); late SettingsCubit settings = context.read<SettingsCubit>();
late bool openExternal; late bool openExternal;
@@ -92,6 +92,12 @@ class _FileViewerState extends State<FileViewer> {
super.initState(); super.initState();
} }
@override
void dispose() {
photoViewController.dispose();
super.dispose();
}
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
AppBar appbar({List<Widget> actions = const []}) => AppBar( AppBar appbar({List<Widget> actions = const []}) => AppBar(
@@ -196,9 +202,7 @@ class _FileViewerState extends State<FileViewer> {
if (!context.mounted) return; if (!context.mounted) return;
Navigator.of(context).pop(); Navigator.of(context).pop();
if (result.type != ResultType.done) { if (result.type != ResultType.done) {
showDialog(context: context, builder: (context) => AlertDialog( InfoDialog.show(context, result.message);
content: Text(result.message),
));
} }
}); });
+32 -2
View File
@@ -1,6 +1,7 @@
import 'dart:async'; import 'dart:async';
import 'dart:io'; import 'dart:io';
import 'package:dio/dio.dart';
import 'package:flutter_test/flutter_test.dart'; import 'package:flutter_test/flutter_test.dart';
import 'package:http/http.dart' as http; import 'package:http/http.dart' as http;
import 'package:marianum_mobile/api/api_error.dart'; import 'package:marianum_mobile/api/api_error.dart';
@@ -36,8 +37,9 @@ void main() {
}); });
test('HandshakeException maps to a TLS-specific message', () { test('HandshakeException maps to a TLS-specific message', () {
expect(errorToUserMessage(const HandshakeException('bad cert')), final message = errorToUserMessage(const HandshakeException('bad cert'));
'Sichere Verbindung konnte nicht hergestellt werden.'); expect(message, contains('sichere Verbindung'));
expect(message, contains('Geräte-Uhrzeit'));
}); });
test('FormatException maps to ParseException message', () { test('FormatException maps to ParseException message', () {
@@ -63,6 +65,34 @@ void main() {
test('custom fallback overrides the default', () { test('custom fallback overrides the default', () {
expect(errorToUserMessage(null, fallback: 'meins'), 'meins'); expect(errorToUserMessage(null, fallback: 'meins'), 'meins');
}); });
test('DioException connectionTimeout maps to timeout NetworkException', () {
final ex = DioException(
requestOptions: RequestOptions(path: '/x'),
type: DioExceptionType.connectionTimeout,
);
expect(errorToUserMessage(ex), NetworkException.timeout().userMessage);
});
test('DioException connectionError maps to NetworkException', () {
final ex = DioException(
requestOptions: RequestOptions(path: '/x'),
type: DioExceptionType.connectionError,
);
expect(errorToUserMessage(ex), const NetworkException().userMessage);
});
test('DioException badResponse maps to a server status message', () {
final ex = DioException(
requestOptions: RequestOptions(path: '/x'),
type: DioExceptionType.badResponse,
response: Response(
requestOptions: RequestOptions(path: '/x'),
statusCode: 503,
),
);
expect(errorToUserMessage(ex), contains('503'));
});
}); });
group('errorToTechnicalDetails', () { group('errorToTechnicalDetails', () {