From 9e139b5704d5ea0c5b9728ebd3d1f92b70d90856 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Elias=20M=C3=BCller?= Date: Fri, 8 May 2026 20:01:45 +0200 Subject: [PATCH] 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 --- lib/api/errors/error_mapper.dart | 52 +++++- lib/api/holidays/get_holidays.dart | 16 -- lib/api/holidays/get_holidays_cache.dart | 18 -- lib/api/holidays/get_holidays_response.dart | 38 ---- lib/api/holidays/get_holidays_response.g.dart | 49 ------ lib/api/marianumcloud/nextcloud_ocs.dart | 14 +- .../talk/room/get_room_response.dart | 32 ---- lib/api/request_cache.dart | 34 +++- lib/notification/notification_controller.dart | 18 +- lib/routing/app_routes.dart | 51 ++---- .../data_loader/data_loader.dart | 14 +- .../chat_list_data_provider.dart | 26 ++- .../data_provider/files_data_provider.dart | 28 ++- .../timetable_data_provider.dart | 151 +++++++--------- lib/utils/clipboard_helper.dart | 3 +- lib/view/pages/files/files_upload_dialog.dart | 36 +--- .../pages/files/widgets/file_element.dart | 162 ++++++++---------- .../files/widgets/files_sort_actions.dart | 3 - lib/view/pages/holidays/holidays_view.dart | 49 +++--- .../more/share/select_share_type_dialog.dart | 69 ++++---- lib/view/pages/overhang.dart | 5 +- .../settings/sections/about_section.dart | 77 ++++----- .../settings/sections/dev_tools_section.dart | 73 ++++---- .../pages/settings/widgets/privacy_info.dart | 42 +++-- lib/view/pages/talk/chat_list.dart | 9 +- lib/view/pages/talk/widgets/bubble.dart | 5 +- lib/view/pages/talk/widgets/chat_bubble.dart | 3 - .../widgets/chat_message_options_dialog.dart | 121 +++++++------ lib/view/pages/talk/widgets/chat_tile.dart | 12 +- .../pages/talk/widgets/poll_options_list.dart | 1 - .../pages/timetable/data/calendar_layout.dart | 20 ++- .../timetable/widgets/appointment_tile.dart | 15 +- .../widgets/calendar/outside_chips.dart | 43 ++--- .../timetable/widgets/calendar/week_grid.dart | 4 +- .../async_actions/async_text_button.dart | 7 +- lib/widget/file_viewer.dart | 14 +- test/api/errors/error_mapper_test.dart | 34 +++- 37 files changed, 595 insertions(+), 753 deletions(-) delete mode 100644 lib/api/holidays/get_holidays.dart delete mode 100644 lib/api/holidays/get_holidays_cache.dart delete mode 100644 lib/api/holidays/get_holidays_response.dart delete mode 100644 lib/api/holidays/get_holidays_response.g.dart diff --git a/lib/api/errors/error_mapper.dart b/lib/api/errors/error_mapper.dart index 313fd50..1807359 100644 --- a/lib/api/errors/error_mapper.dart +++ b/lib/api/errors/error_mapper.dart @@ -1,6 +1,7 @@ import 'dart:async'; import 'dart:io'; +import 'package:dio/dio.dart'; import 'package:http/http.dart' as http; import '../api_error.dart'; @@ -9,10 +10,46 @@ import '../webuntis/webuntis_error.dart'; import 'app_exception.dart'; import 'network_exception.dart'; import 'parse_exception.dart'; +import 'server_exception.dart'; import 'talk_exception.dart'; import 'webuntis_exception.dart'; 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}) { 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 WebuntisError) return WebuntisException(error).userMessage; + if (error is DioException) { + final mapped = _dioToAppException(error); + if (mapped != null) return mapped.userMessage; + } + if (error is SocketException) { return const NetworkException().userMessage; } @@ -31,7 +73,7 @@ String errorToUserMessage(Object? error, {String fallback = _defaultFallback}) { return const NetworkException().userMessage; } if (error is HandshakeException) { - return 'Sichere Verbindung konnte nicht hergestellt werden.'; + return _tlsErrorMessage; } if (error is FormatException) { return const ParseException().userMessage; @@ -48,12 +90,20 @@ String? errorToTechnicalDetails(Object? error) { 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; + if (error is DioException) { + final mapped = _dioToAppException(error); + if (mapped != null) return mapped.technicalDetails ?? mapped.toString(); + } return error.toString(); } bool errorAllowsRetry(Object? error) { if (error == null) return true; if (error is AppException) return error.allowRetry; + if (error is DioException) { + final mapped = _dioToAppException(error); + if (mapped != null) return mapped.allowRetry; + } return true; } diff --git a/lib/api/holidays/get_holidays.dart b/lib/api/holidays/get_holidays.dart deleted file mode 100644 index 5014c48..0000000 --- a/lib/api/holidays/get_holidays.dart +++ /dev/null @@ -1,16 +0,0 @@ -import 'dart:convert'; -import 'package:http/http.dart' as http; - -import 'get_holidays_response.dart'; - -class GetHolidays { - Future query() async { - var response = (await http.get(Uri.parse('https://ferien-api.de/api/v1/holidays/HE'))).body; - var data = jsonDecode(response) as List; - return GetHolidaysResponse( - List.from( - data.map((e) => GetHolidaysResponseObject.fromJson(e as Map)) - ) - ); - } -} diff --git a/lib/api/holidays/get_holidays_cache.dart b/lib/api/holidays/get_holidays_cache.dart deleted file mode 100644 index 5781b59..0000000 --- a/lib/api/holidays/get_holidays_cache.dart +++ /dev/null @@ -1,18 +0,0 @@ -import '../request_cache.dart'; -import 'get_holidays.dart'; -import 'get_holidays_response.dart'; - -class GetHolidaysCache extends SimpleCache { - 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)) - .toList(), - ), - ) { - start('state-holidays'); - } -} diff --git a/lib/api/holidays/get_holidays_response.dart b/lib/api/holidays/get_holidays_response.dart deleted file mode 100644 index 7039417..0000000 --- a/lib/api/holidays/get_holidays_response.dart +++ /dev/null @@ -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 data; - - GetHolidaysResponse(this.data); - - factory GetHolidaysResponse.fromJson(Map json) => _$GetHolidaysResponseFromJson(json); - Map 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 json) => _$GetHolidaysResponseObjectFromJson(json); - Map toJson() => _$GetHolidaysResponseObjectToJson(this); -} diff --git a/lib/api/holidays/get_holidays_response.g.dart b/lib/api/holidays/get_holidays_response.g.dart deleted file mode 100644 index 593ad0b..0000000 --- a/lib/api/holidays/get_holidays_response.g.dart +++ /dev/null @@ -1,49 +0,0 @@ -// GENERATED CODE - DO NOT MODIFY BY HAND - -part of 'get_holidays_response.dart'; - -// ************************************************************************** -// JsonSerializableGenerator -// ************************************************************************** - -GetHolidaysResponse _$GetHolidaysResponseFromJson(Map json) => - GetHolidaysResponse( - (json['data'] as List) - .map( - (e) => - GetHolidaysResponseObject.fromJson(e as Map), - ) - .toList(), - ) - ..headers = (json['headers'] as Map?)?.map( - (k, e) => MapEntry(k, e as String), - ); - -Map _$GetHolidaysResponseToJson( - GetHolidaysResponse instance, -) => { - 'headers': ?instance.headers, - 'data': instance.data.map((e) => e.toJson()).toList(), -}; - -GetHolidaysResponseObject _$GetHolidaysResponseObjectFromJson( - Map 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 _$GetHolidaysResponseObjectToJson( - GetHolidaysResponseObject instance, -) => { - 'start': instance.start, - 'end': instance.end, - 'year': instance.year, - 'stateCode': instance.stateCode, - 'name': instance.name, - 'slug': instance.slug, -}; diff --git a/lib/api/marianumcloud/nextcloud_ocs.dart b/lib/api/marianumcloud/nextcloud_ocs.dart index 64c04f7..b04d770 100644 --- a/lib/api/marianumcloud/nextcloud_ocs.dart +++ b/lib/api/marianumcloud/nextcloud_ocs.dart @@ -1,27 +1,17 @@ import '../../model/account_data.dart'; import '../../model/endpoint_data.dart'; -/// Shared helpers for Nextcloud OCS v2 endpoints. -/// -/// Three call sites previously duplicated the same header dictionary and the -/// same URI scaffolding (TalkApi, AutocompleteApi, FileSharingApi). Anything -/// that talks to `https:////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. +/// Shared headers and URI builder for Nextcloud OCS v2 endpoints. Used by +/// TalkApi, AutocompleteApi, FileSharingApi. class NextcloudOcs { NextcloudOcs._(); - /// The standard OCS request header set: JSON accept, OCS API marker, - /// HTTP Basic auth from the active [AccountData]. static Map headers() => { 'Accept': 'application/json', 'OCS-APIRequest': 'true', '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? queryParameters}) { final endpoint = EndpointData().nextcloud(); return Uri.https( diff --git a/lib/api/marianumcloud/talk/room/get_room_response.dart b/lib/api/marianumcloud/talk/room/get_room_response.dart index c2ce467..da278d1 100644 --- a/lib/api/marianumcloud/talk/room/get_room_response.dart +++ b/lib/api/marianumcloud/talk/room/get_room_response.dart @@ -120,38 +120,6 @@ enum GetRoomResponseObjectParticipantNotificationLevel { @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 json) => _$GetRoomResponseObjectMessageFromJson(json); -// Map toJson() => _$GetRoomResponseObjectMessageToJson(this); -// } - enum GetRoomResponseObjectMessageActorType { @JsonValue('deleted_users') deletedUsers, @JsonValue('users') user, diff --git a/lib/api/request_cache.dart b/lib/api/request_cache.dart index 8d6fdb6..a5614e2 100644 --- a/lib/api/request_cache.dart +++ b/lib/api/request_cache.dart @@ -4,6 +4,7 @@ import 'dart:convert'; import 'package:localstore/localstore.dart'; import 'api_response.dart'; +import 'errors/parse_exception.dart'; abstract class RequestCache { static const int cacheNothing = 0; @@ -81,10 +82,8 @@ abstract class RequestCache { } -/// Concrete [RequestCache] that delegates the two overrides to functions -/// passed in the constructor. Used to collapse the dozens of one-class-per- -/// endpoint cache files that all just forward to `().run()` and -/// `.fromJson(jsonDecode(...))`. +/// Concrete [RequestCache] that takes the two overrides as constructor +/// callbacks instead of requiring a subclass per endpoint. class SimpleCache extends RequestCache { final Future Function() _loader; final T Function(Map json) _fromJson; @@ -115,3 +114,30 @@ class SimpleCache extends RequestCache { @override T onLocalData(String json) => _fromJson(jsonDecode(json) as Map); } + +/// 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 resolveFromCache( + RequestCache 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, + ); +} diff --git a/lib/notification/notification_controller.dart b/lib/notification/notification_controller.dart index a9de28b..162ff28 100644 --- a/lib/notification/notification_controller.dart +++ b/lib/notification/notification_controller.dart @@ -3,6 +3,7 @@ import 'package:flutter/material.dart'; import '../widget/debug/debug_tile.dart'; import '../widget/debug/json_viewer.dart'; +import '../widget/info_dialog.dart'; import 'notification_tasks.dart'; class NotificationController { @@ -22,16 +23,13 @@ class NotificationController { NotificationTasks.updateProviders(context); DebugTile(context).run(() { - showDialog(context: context, builder: (context) => AlertDialog( - title: const Text('Notification report'), - content: Column( - mainAxisSize: MainAxisSize.min, - children: [ - const Text('Dieser Bericht wird angezeigt, da du den Entwicklermodus aktiviert hast und die App über eine Benachrichtigung geöffnet wurde.'), - Text(JsonViewer.format(message.data)), - ], - ), - )); + InfoDialog.show( + context, + 'Dieser Bericht wird angezeigt, da du den Entwicklermodus aktiviert hast und die App über eine Benachrichtigung geöffnet wurde.\n\n' + '${JsonViewer.format(message.data)}', + copyable: true, + title: 'Notification report', + ); }); } diff --git a/lib/routing/app_routes.dart b/lib/routing/app_routes.dart index bbe31c7..dd7bb97 100644 --- a/lib/routing/app_routes.dart +++ b/lib/routing/app_routes.dart @@ -25,22 +25,15 @@ import '../widget/debug/cache_view.dart'; import '../widget/file_viewer.dart'; import '../widget/user_avatar.dart'; -/// Single entry point for full-page navigations across the app. -/// -/// 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. +/// Single entry point for full-page navigations. Dialogs and bottom sheets +/// stay at the call sites; only full-page pushes go through here. class AppRoutes { AppRoutes._(); - /// Token of a chat that should be auto-opened in the Talk tab once - /// the chat list view picks it up. Set by [openChatByToken] (e.g. from - /// a tapped notification) and consumed by the `ChatList` widget. + /// Set by [openChatByToken] (e.g. from a tapped notification) and consumed + /// by `ChatList` once the matching room is loaded. static final ValueNotifier pendingChatToken = ValueNotifier(null); - // -- Files -------------------------------------------------------------- - static void openFolder(BuildContext context, List path) { pushScreen(context, withNavBar: false, screen: Files(path: path)); } @@ -53,14 +46,10 @@ class AppRoutes { ); } - // -- Timetable ---------------------------------------------------------- - static void openCustomEvents(BuildContext context) { pushScreen(context, withNavBar: false, screen: const CustomEventsView()); } - // -- Marianum Message --------------------------------------------------- - static void openMarianumMessage(BuildContext context, String basePath, MarianumMessage message) { pushScreen( context, @@ -69,8 +58,6 @@ class AppRoutes { ); } - // -- Sharing / Settings / Feedback / DevTools --------------------------- - static void openQrShare(BuildContext context) { pushScreen(context, withNavBar: false, screen: const QrShareView()); } @@ -95,8 +82,6 @@ class AppRoutes { pushScreen(context, withNavBar: false, screen: const Roomplan()); } - // -- Talk --------------------------------------------------------------- - static void openMessageReactions(BuildContext context, String token, int messageId) { pushScreen( 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( BuildContext context, { required GetRoomResponseObject room, @@ -122,9 +105,8 @@ class AppRoutes { context.read().setToken(room.token); } - /// Schedules a chat to be opened in the Talk tab. Use this when only the - /// token is known (e.g. from a tapped notification) — the actual push - /// happens inside the `ChatList` widget once the room is available. + /// Schedules a chat to be opened in the Talk tab once the room is loaded. + /// Use when only the token is known (e.g. from a tapped notification). static void openChatByToken(BuildContext context, String token) { pendingChatToken.value = token; goToTalkTab(context); @@ -135,11 +117,9 @@ class AppRoutes { } } - /// Resolves a pending chat token (set via [openChatByToken]) using the - /// [ChatListBloc]'s current rooms and the active [AccountData] credentials. - /// Returns `null` if the token cannot yet be matched (e.g. the room is - /// still being loaded). Callers should keep listening to [pendingChatToken] - /// and the bloc state and retry when either changes. + /// Resolves a pending chat token (set via [openChatByToken]). Returns null + /// if the room or account is not ready yet — callers should retry when + /// [pendingChatToken] or the bloc state change. static ResolvedPendingChat? resolvePendingChat(BuildContext context) { final token = pendingChatToken.value; if (token == null) return null; @@ -166,17 +146,14 @@ class AppRoutes { return null; } - // -- Module / Tab navigation ------------------------------------------- - - /// 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. + /// Pushes a module from the "Mehr" tab list. Modules already in the bottom + /// bar are switched to via [goToTab] instead. static void openModule(BuildContext context, AppModule module) { pushScreen(context, withNavBar: false, screen: module.create()); } - /// Switches the bottom navigation to the given [module] if it is currently - /// in the bottom bar. Returns `true` if the jump happened. + /// Switches the bottom navigation to [module]. Returns false when the + /// module is not currently in the bottom bar. static bool goToTab(BuildContext context, Modules module) { final index = AppModule.getBottomBarModules(context) .map((e) => e.module) @@ -187,8 +164,6 @@ class AppRoutes { return true; } - /// Convenience wrapper for the Talk tab — preserved for the notification - /// handler API which only knows about Talk. static void goToTalkTab(BuildContext context) { goToTab(context, Modules.talk); } diff --git a/lib/state/app/infrastructure/data_loader/data_loader.dart b/lib/state/app/infrastructure/data_loader/data_loader.dart index ceec932..fabb054 100644 --- a/lib/state/app/infrastructure/data_loader/data_loader.dart +++ b/lib/state/app/infrastructure/data_loader/data_loader.dart @@ -12,21 +12,15 @@ abstract class DataLoader { } Future run() async { - var fetcher = fetch(); - await Future.wait([ - fetcher, - Future.delayed(const Duration(milliseconds: 500)) // TODO tune or remove - ]); - - var response = await fetcher; + final response = await fetch(); try { return assemble(DataLoaderResult( json: jsonDecode(response.data!), headers: response.headers.map.map((key, value) => MapEntry(key, value.join(';'))), )); - } catch(trace, e) { - log(trace.toString()); - throw e; + } catch (e, stack) { + log('DataLoader assemble failed', error: e, stackTrace: stack); + rethrow; } } diff --git a/lib/state/app/modules/chat_list/data_provider/chat_list_data_provider.dart b/lib/state/app/modules/chat_list/data_provider/chat_list_data_provider.dart index 6549df4..6fe28b0 100644 --- a/lib/state/app/modules/chat_list/data_provider/chat_list_data_provider.dart +++ b/lib/state/app/modules/chat_list/data_provider/chat_list_data_provider.dart @@ -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/room/get_room_cache.dart'; import '../../../../../api/marianumcloud/talk/room/get_room_response.dart'; +import '../../../../../api/request_cache.dart'; class ChatListDataProvider { Future getRooms({ void Function(Object)? onError, bool renew = false, - }) async { - GetRoomResponse? latest; - Object? capturedError; - final cache = GetRoomCache( - renew: renew, - onUpdate: (data) => latest = data, - onError: (e) { - capturedError = e; - onError?.call(e); - }, - ); - await cache.ready; - if (latest != null) return latest!; - throw capturedError ?? Exception('No data and no error from getRooms'); - } + }) => + resolveFromCache( + (onUpdate, onError) => GetRoomCache( + renew: renew, + onUpdate: onUpdate, + onError: onError, + ), + onError: onError, + operationName: 'getRooms', + ); Future createDirectRoom(String invite) => CreateRoom(CreateRoomParams(roomType: 1, invite: invite)).run(); diff --git a/lib/state/app/modules/files/data_provider/files_data_provider.dart b/lib/state/app/modules/files/data_provider/files_data_provider.dart index 708cb29..39913b2 100644 --- a/lib/state/app/modules/files/data_provider/files_data_provider.dart +++ b/lib/state/app/modules/files/data_provider/files_data_provider.dart @@ -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_response.dart'; import '../../../../../api/marianumcloud/webdav/webdav_api.dart'; +import '../../../../../api/request_cache.dart'; class FilesDataProvider { /// Lists files at [path]. Cached payload is delivered via [onCacheData] as @@ -14,22 +15,17 @@ class FilesDataProvider { String path, { void Function(ListFilesResponse)? onCacheData, void Function(Object)? onError, - }) async { - ListFilesResponse? latest; - Object? capturedError; - final cache = ListFilesCache( - path: path, - onUpdate: (data) => latest = data, - onCacheData: onCacheData, - onError: (e) { - capturedError = e; - onError?.call(e); - }, - ); - await cache.ready; - if (latest != null) return latest!; - throw capturedError ?? Exception('No data and no error from listFiles'); - } + }) => + resolveFromCache( + (onUpdate, onError) => ListFilesCache( + path: path, + onUpdate: onUpdate, + onCacheData: onCacheData, + onError: onError, + ), + onError: onError, + operationName: 'listFiles', + ); Future createFolder(String fullPath) async { final webdav = await WebdavApi.webdav; diff --git a/lib/state/app/modules/timetable/data_provider/timetable_data_provider.dart b/lib/state/app/modules/timetable/data_provider/timetable_data_provider.dart index 8859d1d..afd993f 100644 --- a/lib/state/app/modules/timetable/data_provider/timetable_data_provider.dart +++ b/lib/state/app/modules/timetable/data_provider/timetable_data_provider.dart @@ -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/update/update_custom_timetable_event.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_response.dart'; import '../../../../../api/webuntis/queries/get_rooms/get_rooms_cache.dart'; @@ -30,112 +31,84 @@ class TimetableDataProvider { DateTime endDate, { void Function(Object)? onError, bool renew = false, - }) async { - GetTimetableResponse? latest; - Object? capturedError; - final cache = GetTimetableCache( - startdate: int.parse(_dateFormat.format(startDate)), - enddate: int.parse(_dateFormat.format(endDate)), - renew: renew, - onUpdate: (data) => latest = data, - onError: (e) { - capturedError = e; - onError?.call(e); - }, - ); - await cache.ready; - if (latest != null) return latest!; - throw capturedError ?? Exception('No data and no error from getWeek'); - } + }) => + resolveFromCache( + (onUpdate, onError) => GetTimetableCache( + startdate: int.parse(_dateFormat.format(startDate)), + enddate: int.parse(_dateFormat.format(endDate)), + renew: renew, + onUpdate: onUpdate, + onError: onError, + ), + onError: onError, + operationName: 'getWeek', + ); Future getRooms({ void Function(Object)? onError, bool renew = false, - }) async { - GetRoomsResponse? latest; - Object? capturedError; - final cache = GetRoomsCache( - renew: renew, - onUpdate: (data) => latest = data, - onError: (e) { - capturedError = e; - onError?.call(e); - }, - ); - await cache.ready; - if (latest != null) return latest!; - throw capturedError ?? Exception('No data and no error from getRooms'); - } + }) => + resolveFromCache( + (onUpdate, onError) => GetRoomsCache( + renew: renew, + onUpdate: onUpdate, + onError: onError, + ), + onError: onError, + operationName: 'getRooms', + ); Future getSubjects({ void Function(Object)? onError, bool renew = false, - }) async { - GetSubjectsResponse? latest; - Object? capturedError; - final cache = GetSubjectsCache( - renew: renew, - onUpdate: (data) => latest = data, - onError: (e) { - capturedError = e; - onError?.call(e); - }, - ); - await cache.ready; - if (latest != null) return latest!; - throw capturedError ?? Exception('No data and no error from getSubjects'); - } + }) => + resolveFromCache( + (onUpdate, onError) => GetSubjectsCache( + renew: renew, + onUpdate: onUpdate, + onError: onError, + ), + onError: onError, + operationName: 'getSubjects', + ); Future getSchoolHolidays({ void Function(Object)? onError, bool renew = false, - }) async { - GetHolidaysResponse? latest; - Object? capturedError; - final cache = GetHolidaysCache( - renew: renew, - onUpdate: (data) => latest = data, - onError: (e) { - capturedError = e; - onError?.call(e); - }, - ); - await cache.ready; - if (latest != null) return latest!; - throw capturedError ?? Exception('No data and no error from getSchoolHolidays'); - } + }) => + resolveFromCache( + (onUpdate, onError) => GetHolidaysCache( + renew: renew, + onUpdate: onUpdate, + onError: onError, + ), + onError: onError, + operationName: 'getSchoolHolidays', + ); - Future getTimegrid({bool renew = false}) async { - GetTimegridUnitsResponse? latest; - Object? capturedError; - final cache = GetTimegridUnitsCache( - renew: renew, - onUpdate: (data) => latest = data, - ); - await cache.ready; - if (latest != null) return latest!; - throw capturedError ?? Exception('No data and no error from getTimegrid'); - } + Future getTimegrid({bool renew = false}) => + resolveFromCache( + (onUpdate, _) => GetTimegridUnitsCache( + renew: renew, + onUpdate: onUpdate, + ), + operationName: 'getTimegrid', + ); Future getCustomEvents({ bool renew = false, void Function(Object)? onError, - }) async { - GetCustomTimetableEventResponse? latest; - Object? capturedError; - final cache = GetCustomTimetableEventCache( - GetCustomTimetableEventParams(AccountData().getUserSecret()), - renew: renew, - onUpdate: (data) => latest = data, - onError: (e) { - capturedError = e; - onError?.call(e); - }, - ); - await cache.ready; - if (latest != null) return latest!; - throw capturedError ?? Exception('No data and no error from getCustomEvents'); - } + }) => + resolveFromCache( + (onUpdate, onError) => GetCustomTimetableEventCache( + GetCustomTimetableEventParams(AccountData().getUserSecret()), + renew: renew, + onUpdate: onUpdate, + onError: onError, + ), + onError: onError, + operationName: 'getCustomEvents', + ); Future addCustomEvent(CustomTimetableEvent event) => AddCustomTimetableEvent(AddCustomTimetableEventParams(AccountData().getUserSecret(), event)).run(); diff --git a/lib/utils/clipboard_helper.dart b/lib/utils/clipboard_helper.dart index df08022..54c56f1 100644 --- a/lib/utils/clipboard_helper.dart +++ b/lib/utils/clipboard_helper.dart @@ -1,8 +1,7 @@ import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; -/// Copies [text] to the system clipboard and shows a SnackBar confirmation. -/// Safe to await: respects context lifecycle via the provided [context]. +/// Copies [text] to the system clipboard and shows a SnackBar. Future copyToClipboard( BuildContext context, String text, { diff --git a/lib/view/pages/files/files_upload_dialog.dart b/lib/view/pages/files/files_upload_dialog.dart index 44fe7cf..b7d72b1 100644 --- a/lib/view/pages/files/files_upload_dialog.dart +++ b/lib/view/pages/files/files_upload_dialog.dart @@ -7,6 +7,7 @@ import 'package:nextcloud/nextcloud.dart'; import '../../../api/marianumcloud/webdav/webdav_api.dart'; import '../../../widget/confirm_dialog.dart'; import '../../../widget/focus_behaviour.dart'; +import '../../../widget/info_dialog.dart'; class FilesUploadDialog extends StatefulWidget { final List filePaths; @@ -47,20 +48,12 @@ class _FilesUploadDialogState extends State { }).toList(); } - void showHttpErrorCode(int httpErrorCode){ - showDialog( - context: context, - builder: (BuildContext context) => AlertDialog( - title: const Text('Ein Fehler ist aufgetreten'), - contentPadding: const EdgeInsets.all(10), - content: Text('Error code: $httpErrorCode'), - actions: [ - TextButton( - onPressed: () => Navigator.of(context).pop(), - child: const Text('Schließen', textAlign: TextAlign.center), - ), - ], - ) + void showHttpErrorCode(int httpErrorCode) { + InfoDialog.show( + context, + 'Error code: $httpErrorCode', + title: 'Ein Fehler ist aufgetreten', + copyable: true, ); } @@ -70,20 +63,7 @@ class _FilesUploadDialogState extends State { _overallProgressValue = 0.0; _infoText = ''; }); - showDialog( - 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), - ), - ], - ), - ); + InfoDialog.show(context, message, title: 'Upload fehlgeschlagen', copyable: true); } Future uploadFiles({bool override = false}) async { diff --git a/lib/view/pages/files/widgets/file_element.dart b/lib/view/pages/files/widgets/file_element.dart index d70d856..82a8dc1 100644 --- a/lib/view/pages/files/widgets/file_element.dart +++ b/lib/view/pages/files/widgets/file_element.dart @@ -11,6 +11,7 @@ import '../../../../utils/download_manager.dart'; import '../../../../utils/file_clipboard.dart'; import '../../../../widget/centered_leading.dart'; import '../../../../widget/confirm_dialog.dart'; +import '../../../../widget/details_bottom_sheet.dart'; import '../../../../widget/info_dialog.dart'; import 'file_details_sheet.dart'; @@ -77,13 +78,7 @@ class _FileElementState extends State { DownloadManager.instance.clear(widget.file.path); _detachJob(); setState(() {}); - showDialog( - context: context, - builder: (context) => AlertDialog( - title: const Text('Download'), - content: Text(message), - ), - ); + InfoDialog.show(context, message, title: 'Download', copyable: true); } else if (status is DownloadCancelled) { DownloadManager.instance.clear(widget.file.path); _detachJob(); @@ -172,32 +167,36 @@ class _FileElementState extends State { Future _rename() async { final controller = TextEditingController(text: widget.file.name); - final newName = await showDialog( - context: context, - builder: (dialogCtx) => AlertDialog( - title: const Text('Umbenennen'), - content: TextField( - controller: controller, - decoration: const InputDecoration(labelText: 'Neuer Name'), - autofocus: true, - ), - actions: [ - TextButton(onPressed: () => Navigator.of(dialogCtx).pop(), child: const Text('Abbrechen')), - TextButton( - onPressed: () => Navigator.of(dialogCtx).pop(controller.text.trim()), - child: const Text('Umbenennen'), + try { + final newName = await showDialog( + context: context, + builder: (dialogCtx) => AlertDialog( + title: const Text('Umbenennen'), + content: TextField( + controller: controller, + decoration: const InputDecoration(labelText: 'Neuer Name'), + autofocus: true, ), - ], - ), - ); - if (newName == null || newName.isEmpty || newName == widget.file.name) return; + actions: [ + TextButton(onPressed: () => Navigator.of(dialogCtx).pop(), child: const Text('Abbrechen')), + TextButton( + onPressed: () => Navigator.of(dialogCtx).pop(controller.text.trim()), + child: const Text('Umbenennen'), + ), + ], + ), + ); + if (newName == null || newName.isEmpty || newName == widget.file.name) return; - final parent = _parentPathOf(widget.file.path); - final destination = _joinPath(parent, newName, isDirectory: widget.file.isDirectory); - await _runWebdavOp(() async { - final webdav = await WebdavApi.webdav; - await webdav.move(PathUri.parse(widget.file.path), PathUri.parse(destination)); - }, errorTitle: 'Umbenennen fehlgeschlagen'); + final parent = _parentPathOf(widget.file.path); + final destination = _joinPath(parent, newName, isDirectory: widget.file.isDirectory); + await _runWebdavOp(() async { + final webdav = await WebdavApi.webdav; + await webdav.move(PathUri.parse(widget.file.path), PathUri.parse(destination)); + }, errorTitle: 'Umbenennen fehlgeschlagen'); + } finally { + controller.dispose(); + } } void _putOnClipboard({required bool copy}) { @@ -234,68 +233,55 @@ class _FileElementState extends State { widget.refetch(); } on Object catch (e) { if (!mounted) return; - await showDialog( - context: context, - builder: (dialogCtx) => AlertDialog( - title: Text(errorTitle), - content: Text(e.toString()), - actions: [TextButton(onPressed: () => Navigator.of(dialogCtx).pop(), child: const Text('OK'))], - ), - ); + InfoDialog.show(context, e.toString(), title: errorTitle, copyable: true); } } void _showActionSheet() { - showModalBottomSheet( - context: context, - showDragHandle: true, - builder: (sheetCtx) => SafeArea( - child: Column( - mainAxisSize: MainAxisSize.min, - children: [ - ListTile( - leading: const CenteredLeading(Icon(Icons.info_outline)), - title: const Text('Info'), - onTap: () { - Navigator.of(sheetCtx).pop(); - showFileDetailsSheet(context, widget.file); - }, - ), - ListTile( - leading: const CenteredLeading(Icon(Icons.drive_file_rename_outline)), - title: const Text('Umbenennen'), - onTap: () { - Navigator.of(sheetCtx).pop(); - _rename(); - }, - ), - ListTile( - leading: const CenteredLeading(Icon(Icons.drive_file_move_outline)), - title: const Text('Verschieben'), - onTap: () { - Navigator.of(sheetCtx).pop(); - _putOnClipboard(copy: false); - }, - ), - ListTile( - leading: const CenteredLeading(Icon(Icons.copy_outlined)), - title: const Text('Kopieren'), - onTap: () { - Navigator.of(sheetCtx).pop(); - _putOnClipboard(copy: true); - }, - ), - ListTile( - leading: const CenteredLeading(Icon(Icons.delete_outline)), - title: const Text('Löschen'), - onTap: () { - Navigator.of(sheetCtx).pop(); - _delete(); - }, - ), - ], + showDetailsBottomSheet( + context, + children: (sheetCtx) => [ + ListTile( + leading: const CenteredLeading(Icon(Icons.info_outline)), + title: const Text('Info'), + onTap: () { + Navigator.of(sheetCtx).pop(); + showFileDetailsSheet(context, widget.file); + }, ), - ), + ListTile( + leading: const CenteredLeading(Icon(Icons.drive_file_rename_outline)), + title: const Text('Umbenennen'), + onTap: () { + Navigator.of(sheetCtx).pop(); + _rename(); + }, + ), + ListTile( + leading: const CenteredLeading(Icon(Icons.drive_file_move_outline)), + title: const Text('Verschieben'), + onTap: () { + Navigator.of(sheetCtx).pop(); + _putOnClipboard(copy: false); + }, + ), + ListTile( + leading: const CenteredLeading(Icon(Icons.copy_outlined)), + title: const Text('Kopieren'), + onTap: () { + Navigator.of(sheetCtx).pop(); + _putOnClipboard(copy: true); + }, + ), + ListTile( + leading: const CenteredLeading(Icon(Icons.delete_outline)), + title: const Text('Löschen'), + onTap: () { + Navigator.of(sheetCtx).pop(); + _delete(); + }, + ), + ], ); } diff --git a/lib/view/pages/files/widgets/files_sort_actions.dart b/lib/view/pages/files/widgets/files_sort_actions.dart index 58abb23..269f725 100644 --- a/lib/view/pages/files/widgets/files_sort_actions.dart +++ b/lib/view/pages/files/widgets/files_sort_actions.dart @@ -2,9 +2,6 @@ import 'package:flutter/material.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 { final SortOption currentSort; final bool ascending; diff --git a/lib/view/pages/holidays/holidays_view.dart b/lib/view/pages/holidays/holidays_view.dart index 4fa4fac..21241b7 100644 --- a/lib/view/pages/holidays/holidays_view.dart +++ b/lib/view/pages/holidays/holidays_view.dart @@ -10,6 +10,8 @@ import '../../../state/app/modules/holidays/bloc/holidays_state.dart'; import '../../../widget/animated_time.dart'; import '../../../widget/centered_leading.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/string_extensions.dart'; @@ -21,18 +23,13 @@ class HolidaysView extends StatelessWidget { create: (context) => HolidaysBloc(), autoRebuild: true, child: (context, bloc, state) { - void showDisclaimer() { - showDialog(context: context, builder: (context) => AlertDialog( - title: const Text('Richtigkeit und Bereitstellung der Daten'), - content: const Text('' - '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' - 'Die Daten stammen von https://ferien-api.de/'), - actions: [ - TextButton(child: const Text('Okay'), onPressed: () => Navigator.of(context).pop()), - ], - )); - } + void showDisclaimer() => InfoDialog.show( + context, + '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' + 'Die Daten stammen von https://ferien-api.de/', + title: 'Richtigkeit und Bereitstellung der Daten', + ); return Scaffold( appBar: AppBar( @@ -78,9 +75,16 @@ class HolidaysView extends StatelessWidget { leading: const CenteredLeading(Icon(Icons.calendar_month)), title: Text('$holidayType ${getHolidayYear(holiday.start, holiday.end)}'), subtitle: Text('${formatDate(holiday.start)} - ${formatDate(holiday.end)}'), - onTap: () => showDialog(context: context, builder: (context) => SimpleDialog( - title: Text('$holidayType ${holiday.year} in Hessen'), - children: [ + onTap: () => showDetailsBottomSheet( + context, + 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( leading: const CenteredLeading(Icon(Icons.signpost_outlined)), title: Text(holiday.name.capitalize()), @@ -94,21 +98,20 @@ class HolidaysView extends StatelessWidget { leading: const Icon(Icons.date_range_outlined), title: Text('bis zum ${formatDate(holiday.end)}'), ), - Visibility( - visible: !DateTime.parse(holiday.start).difference(DateTime.now()).isNegative, - replacement: ListTile( + if (DateTime.parse(holiday.start).difference(DateTime.now()).isNegative) + ListTile( leading: const CenteredLeading(Icon(Icons.content_paste_search_outlined)), title: Text(Jiffy.parse(holiday.start).fromNow()), - ), - child: ListTile( + ) + else + ListTile( leading: const CenteredLeading(Icon(Icons.timer_outlined)), title: AnimatedTime(callback: () => DateTime.parse(holiday.start).difference(DateTime.now())), subtitle: Text(Jiffy.parse(holiday.start).fromNow()), ), - ), - DebugTile(context).jsonData(holiday.toJson()), + DebugTile(sheetCtx).jsonData(holiday.toJson()), ], - )), + ), trailing: const Icon(Icons.arrow_right), ); }), diff --git a/lib/view/pages/more/share/select_share_type_dialog.dart b/lib/view/pages/more/share/select_share_type_dialog.dart index 50c9491..a1df4f4 100644 --- a/lib/view/pages/more/share/select_share_type_dialog.dart +++ b/lib/view/pages/more/share/select_share_type_dialog.dart @@ -5,34 +5,43 @@ import '../../../../widget/share_position_origin.dart'; enum ShareTargetType { qr } -class SelectShareTypeDialog extends StatelessWidget { - const SelectShareTypeDialog({super.key}); - - @override - Widget build(BuildContext context) => SimpleDialog( - children: [ - ListTile( - leading: const Icon(Icons.qr_code_2_outlined), - title: const Text('Per QR-Code'), - trailing: const Icon(Icons.arrow_right), - onTap: () => Navigator.of(context).pop(ShareTargetType.qr), - ), - ListTile( - leading: const Icon(Icons.link_outlined), - title: const Text('Per Link teilen'), - trailing: const Icon(Icons.arrow_right), - onTap: () { - Navigator.of(context).pop(); - SharePlus.instance.share(ShareParams( - sharePositionOrigin: SharePositionOrigin.get(context), - subject: 'App Teilen', - 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 ' - '\niOS: https://apps.apple.com/us/app/marianum-fulda/id6458789560 ' - '\n\nViel Spaß!', - )); - }, - ) - ], - ); +/// Bottom sheet that lets the user pick how they want to share the app. +/// Resolves with [ShareTargetType.qr] for the QR option, or `null` when the +/// sheet is dismissed (link sharing fires immediately and resolves null). +Future showSelectShareTypeSheet(BuildContext context) { + return showModalBottomSheet( + context: context, + isScrollControlled: true, + showDragHandle: true, + useSafeArea: true, + builder: (sheetCtx) => SafeArea( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + ListTile( + leading: const Icon(Icons.qr_code_2_outlined), + title: const Text('Per QR-Code'), + trailing: const Icon(Icons.arrow_right), + onTap: () => Navigator.of(sheetCtx).pop(ShareTargetType.qr), + ), + ListTile( + leading: const Icon(Icons.link_outlined), + title: const Text('Per Link teilen'), + trailing: const Icon(Icons.arrow_right), + onTap: () { + Navigator.of(sheetCtx).pop(); + SharePlus.instance.share(ShareParams( + sharePositionOrigin: SharePositionOrigin.get(sheetCtx), + subject: 'App Teilen', + 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 ' + '\niOS: https://apps.apple.com/us/app/marianum-fulda/id6458789560 ' + '\n\nViel Spaß!', + )); + }, + ), + ], + ), + ), + ); } diff --git a/lib/view/pages/overhang.dart b/lib/view/pages/overhang.dart index 214d500..e89ee2a 100644 --- a/lib/view/pages/overhang.dart +++ b/lib/view/pages/overhang.dart @@ -42,10 +42,7 @@ class _OverhangState extends State { subtitle: const Text('Mit Freunden und deiner Klasse teilen'), trailing: const Icon(Icons.arrow_right), onTap: () async { - final result = await showDialog( - context: context, - builder: (_) => const SelectShareTypeDialog(), - ); + final result = await showSelectShareTypeSheet(context); if (!mounted || result != ShareTargetType.qr) return; if (context.mounted) AppRoutes.openQrShare(context); }, diff --git a/lib/view/pages/settings/sections/about_section.dart b/lib/view/pages/settings/sections/about_section.dart index e8b3157..4de6616 100644 --- a/lib/view/pages/settings/sections/about_section.dart +++ b/lib/view/pages/settings/sections/about_section.dart @@ -7,6 +7,7 @@ import 'package:package_info_plus/package_info_plus.dart'; import '../../../../state/app/modules/settings/bloc/settings_cubit.dart'; import '../../../../widget/centered_leading.dart'; import '../../../../widget/confirm_dialog.dart'; +import '../../../../widget/details_bottom_sheet.dart'; import '../data/default_settings.dart'; import '../widgets/privacy_info.dart'; import 'dev_tools_section.dart'; @@ -69,45 +70,43 @@ class AboutSection extends StatelessWidget { ); } - void _showPrivacyDialog(BuildContext context) => showDialog( - context: context, - builder: (context) => SimpleDialog( - children: [ - ListTile( - leading: const CenteredLeading(Icon(Icons.school_outlined)), - title: const Text('Infos zum Marianum Fulda'), - subtitle: const Text('Für Talk-Chats und Dateien'), - trailing: const Icon(Icons.arrow_right), - onTap: () => PrivacyInfo( - providerText: 'Marianum', - imprintUrl: 'https://www.marianum-fulda.de/impressum', - privacyUrl: 'https://www.marianum-fulda.de/datenschutz', - ).showPopup(context), - ), - ListTile( - leading: const CenteredLeading(Icon(Icons.date_range_outlined)), - title: const Text('Infos zu Web-/ Untis'), - subtitle: const Text('Für den Stundenplan'), - trailing: const Icon(Icons.arrow_right), - onTap: () => PrivacyInfo( - providerText: 'Untis', - imprintUrl: 'https://www.untis.at/impressum', - privacyUrl: 'https://www.untis.at/datenschutz-wu-apps', - ).showPopup(context), - ), - ListTile( - leading: const CenteredLeading(Icon(Icons.send_time_extension_outlined)), - title: const Text('Infos zu mhsl'), - subtitle: const Text('Für Countdowns, Marianum Message und mehr'), - trailing: const Icon(Icons.arrow_right), - onTap: () => PrivacyInfo( - providerText: 'mhsl', - imprintUrl: 'https://mhsl.eu/id.html', - privacyUrl: 'https://mhsl.eu/datenschutz.html', - ).showPopup(context), - ), - ], - ), + void _showPrivacyDialog(BuildContext context) => showDetailsBottomSheet( + context, + children: (sheetCtx) => [ + ListTile( + leading: const CenteredLeading(Icon(Icons.school_outlined)), + title: const Text('Infos zum Marianum Fulda'), + subtitle: const Text('Für Talk-Chats und Dateien'), + trailing: const Icon(Icons.arrow_right), + onTap: () => PrivacyInfo( + providerText: 'Marianum', + imprintUrl: 'https://www.marianum-fulda.de/impressum', + privacyUrl: 'https://www.marianum-fulda.de/datenschutz', + ).showPopup(sheetCtx), + ), + ListTile( + leading: const CenteredLeading(Icon(Icons.date_range_outlined)), + title: const Text('Infos zu Web-/ Untis'), + subtitle: const Text('Für den Stundenplan'), + trailing: const Icon(Icons.arrow_right), + onTap: () => PrivacyInfo( + providerText: 'Untis', + imprintUrl: 'https://www.untis.at/impressum', + privacyUrl: 'https://www.untis.at/datenschutz-wu-apps', + ).showPopup(sheetCtx), + ), + ListTile( + leading: const CenteredLeading(Icon(Icons.send_time_extension_outlined)), + title: const Text('Infos zu mhsl'), + subtitle: const Text('Für Countdowns, Marianum Message und mehr'), + trailing: const Icon(Icons.arrow_right), + onTap: () => PrivacyInfo( + providerText: 'mhsl', + imprintUrl: 'https://mhsl.eu/id.html', + privacyUrl: 'https://mhsl.eu/datenschutz.html', + ).showPopup(sheetCtx), + ), + ], ); void _toggleDeveloperMode(BuildContext context, SettingsCubit settings, bool? state) { diff --git a/lib/view/pages/settings/sections/dev_tools_section.dart b/lib/view/pages/settings/sections/dev_tools_section.dart index 7cada5f..82d5019 100644 --- a/lib/view/pages/settings/sections/dev_tools_section.dart +++ b/lib/view/pages/settings/sections/dev_tools_section.dart @@ -11,6 +11,7 @@ import '../../../../widget/centered_leading.dart'; import '../../../../widget/confirm_dialog.dart'; import '../../../../widget/debug/cache_view.dart'; import '../../../../widget/debug/json_viewer.dart'; +import '../../../../widget/details_bottom_sheet.dart'; class DevToolsSection extends StatefulWidget { final SettingsCubit settings; @@ -29,42 +30,45 @@ class _DevToolsSectionState extends State { title: const Text('Performance overlays'), trailing: const Icon(Icons.arrow_right), onTap: () { - showDialog( - context: context, - builder: (dialogCtx) => BlocBuilder( - bloc: widget.settings, - builder: (_, _) { - final dev = widget.settings.val().devToolsSettings; - return SimpleDialog( - children: [ - ListTile( - leading: const Icon(Icons.auto_graph_outlined), - title: const Text('Performance graph'), - trailing: Checkbox( - value: dev.showPerformanceOverlay, - onChanged: (e) => widget.settings.val(write: true).devToolsSettings.showPerformanceOverlay = e!, + showDetailsBottomSheet( + context, + children: (sheetCtx) => [ + BlocBuilder( + bloc: widget.settings, + builder: (_, _) { + final dev = widget.settings.val().devToolsSettings; + return Column( + mainAxisSize: MainAxisSize.min, + children: [ + ListTile( + leading: const Icon(Icons.auto_graph_outlined), + title: const Text('Performance graph'), + trailing: Checkbox( + value: dev.showPerformanceOverlay, + onChanged: (e) => widget.settings.val(write: true).devToolsSettings.showPerformanceOverlay = e!, + ), ), - ), - ListTile( - leading: const Icon(Icons.screen_search_desktop_outlined), - title: const Text('Indicate offscreen layers'), - trailing: Checkbox( - value: dev.checkerboardOffscreenLayers, - onChanged: (e) => widget.settings.val(write: true).devToolsSettings.checkerboardOffscreenLayers = e!, + ListTile( + leading: const Icon(Icons.screen_search_desktop_outlined), + title: const Text('Indicate offscreen layers'), + trailing: Checkbox( + value: dev.checkerboardOffscreenLayers, + onChanged: (e) => widget.settings.val(write: true).devToolsSettings.checkerboardOffscreenLayers = e!, + ), ), - ), - ListTile( - leading: const Icon(Icons.imagesearch_roller_outlined), - title: const Text('Indicate raster cache images'), - trailing: Checkbox( - value: dev.checkerboardRasterCacheImages, - onChanged: (e) => widget.settings.val(write: true).devToolsSettings.checkerboardRasterCacheImages = e!, + ListTile( + leading: const Icon(Icons.imagesearch_roller_outlined), + title: const Text('Indicate raster cache images'), + trailing: Checkbox( + value: dev.checkerboardRasterCacheImages, + onChanged: (e) => widget.settings.val(write: true).devToolsSettings.checkerboardRasterCacheImages = e!, + ), ), - ), - ], - ); - }, - ), + ], + ); + }, + ), + ], ); }, ), @@ -122,9 +126,6 @@ class _DevToolsSectionState extends State { leading: const CenteredLeading(Icon(Icons.data_object)), title: const Text('BLOC-storage state cache'), subtitle: const Text('Lange tippen um zu löschen'), - onTap: () { - // Navigator.push(context, MaterialPageRoute(builder: (context) => const CacheView())); - }, onLongPress: () { ConfirmDialog( title: 'BLOC-Cache löschen', diff --git a/lib/view/pages/settings/widgets/privacy_info.dart b/lib/view/pages/settings/widgets/privacy_info.dart index 13ba668..ec96588 100644 --- a/lib/view/pages/settings/widgets/privacy_info.dart +++ b/lib/view/pages/settings/widgets/privacy_info.dart @@ -2,6 +2,7 @@ import 'package:flutter/material.dart'; import '../../../../widget/centered_leading.dart'; import '../../../../widget/confirm_dialog.dart'; +import '../../../../widget/details_bottom_sheet.dart'; class PrivacyInfo { String providerText; @@ -11,22 +12,29 @@ class PrivacyInfo { PrivacyInfo({required this.providerText, required this.imprintUrl, required this.privacyUrl}); void showPopup(BuildContext context) { - showDialog(context: context, builder: (context) => SimpleDialog( - title: Text('Betreiberinformation | $providerText'), - children: [ - ListTile( - leading: const CenteredLeading(Icon(Icons.person_pin_outlined)), - title: const Text('Impressum'), - subtitle: Text(imprintUrl), - onTap: () => ConfirmDialog.openBrowser(context, imprintUrl), - ), - ListTile( - leading: const CenteredLeading(Icon(Icons.privacy_tip_outlined)), - title: const Text('Datenschutzerklärung'), - subtitle: Text(privacyUrl), - onTap: () => ConfirmDialog.openBrowser(context, privacyUrl), - ), - ], - )); + showDetailsBottomSheet( + context, + header: Padding( + padding: const EdgeInsets.fromLTRB(16, 4, 16, 12), + child: Text( + 'Betreiberinformation | $providerText', + style: Theme.of(context).textTheme.titleLarge, + ), + ), + children: (sheetCtx) => [ + ListTile( + leading: const CenteredLeading(Icon(Icons.person_pin_outlined)), + title: const Text('Impressum'), + subtitle: Text(imprintUrl), + onTap: () => ConfirmDialog.openBrowser(sheetCtx, imprintUrl), + ), + ListTile( + leading: const CenteredLeading(Icon(Icons.privacy_tip_outlined)), + title: const Text('Datenschutzerklärung'), + subtitle: Text(privacyUrl), + onTap: () => ConfirmDialog.openBrowser(sheetCtx, privacyUrl), + ), + ], + ); } } diff --git a/lib/view/pages/talk/chat_list.dart b/lib/view/pages/talk/chat_list.dart index 4a01136..f5bdc85 100644 --- a/lib/view/pages/talk/chat_list.dart +++ b/lib/view/pages/talk/chat_list.dart @@ -12,6 +12,7 @@ import '../../../state/app/modules/chat_list/bloc/chat_list_bloc.dart'; import '../../../state/app/modules/chat_list/bloc/chat_list_state.dart'; import '../../../state/app/modules/settings/bloc/settings_cubit.dart'; import '../../../widget/confirm_dialog.dart'; +import '../../../widget/info_dialog.dart'; import '../../../widget/placeholder_view.dart'; import 'join_chat.dart'; import 'search_chat.dart'; @@ -98,11 +99,9 @@ class _ChatListViewState extends State<_ChatListView> { NotifyUpdater.enableAfterDisclaimer(_settings).asDialog(context); break; case AuthorizationStatus.denied: - showDialog( - context: context, - builder: (_) => const AlertDialog( - content: Text('Du kannst die Benachrichtigungen später jederzeit in den App-Einstellungen aktivieren.'), - ), + InfoDialog.show( + context, + 'Du kannst die Benachrichtigungen später jederzeit in den App-Einstellungen aktivieren.', ); break; default: diff --git a/lib/view/pages/talk/widgets/bubble.dart b/lib/view/pages/talk/widgets/bubble.dart index 408564d..bd87a2a 100644 --- a/lib/view/pages/talk/widgets/bubble.dart +++ b/lib/view/pages/talk/widgets/bubble.dart @@ -40,9 +40,8 @@ class BubbleStyle { final double borderRadius; } -/// Lightweight chat bubble. Replaces the abandoned `bubble` package: renders a -/// rounded container with optional shadow / border. The nip is conveyed by -/// flattening one corner so the bubble visually anchors to the speaker side. +/// The "nip" is faked by flattening one corner so the bubble anchors to +/// the speaker side. class Bubble extends StatelessWidget { const Bubble({required this.child, required this.style, super.key}); diff --git a/lib/view/pages/talk/widgets/chat_bubble.dart b/lib/view/pages/talk/widgets/chat_bubble.dart index f226c8e..8958be9 100644 --- a/lib/view/pages/talk/widgets/chat_bubble.dart +++ b/lib/view/pages/talk/widgets/chat_bubble.dart @@ -246,9 +246,6 @@ class _ChatBubbleState extends State 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 { final Text actorText; final Text timeText; diff --git a/lib/view/pages/talk/widgets/chat_message_options_dialog.dart b/lib/view/pages/talk/widgets/chat_message_options_dialog.dart index 8ba2641..6816d71 100644 --- a/lib/view/pages/talk/widgets/chat_message_options_dialog.dart +++ b/lib/view/pages/talk/widgets/chat_message_options_dialog.dart @@ -14,13 +14,14 @@ import '../../../../utils/clipboard_helper.dart'; import '../../../../widget/app_progress_indicator.dart'; import '../../../../widget/async_action_button.dart'; import '../../../../widget/debug/debug_tile.dart'; +import '../../../../widget/details_bottom_sheet.dart'; const _commonReactions = ['👍', '👎', '😆', '❤️', '👀']; /// Long-press / double-tap options dialog for a single chat message bubble. /// The hosting [ChatBubble] keeps responsibility for rendering the bubble; /// this file owns the modal interactions (react, reply, copy, delete, ...). -Future showChatMessageOptionsDialog( +void showChatMessageOptionsDialog( BuildContext context, { required GetRoomResponseObject chatData, required GetChatResponseObject bubbleData, @@ -34,63 +35,61 @@ Future showChatMessageOptionsDialog( .add(const Duration(hours: 6)) .isAfter(DateTime.now()); - return showDialog( - context: context, - builder: (dialogCtx) => SimpleDialog( - children: [ - if (canReact) - _ReactionsRow( - chatToken: chatData.token, - messageId: bubbleData.id, - onRefetch: onRefetch, - dialogContext: dialogCtx, - ), - if (bubbleData.isReplyable) - ListTile( - leading: const Icon(Icons.reply_outlined), - title: const Text('Antworten'), - onTap: () { - dialogCtx.read().setReferenceMessageId(bubbleData.id); - Navigator.of(dialogCtx).pop(); - }, - ), - if (canReact) - ListTile( - leading: const Icon(Icons.emoji_emotions_outlined), - title: const Text('Reaktionen'), - onTap: () { - Navigator.of(dialogCtx).pop(); - if (!parentContext.mounted) return; - AppRoutes.openMessageReactions(parentContext, chatData.token, bubbleData.id); - }, - ), - if (bubbleData.message != '{file}') - ListTile( - leading: const Icon(Icons.copy), - title: const Text('Nachricht kopieren'), - onTap: () { - copyToClipboard(parentContext, bubbleData.message); - Navigator.of(dialogCtx).pop(); - }, - ), - if (!kReleaseMode && !isSender && chatData.type != GetRoomResponseObjectConversationType.oneToOne) - ListTile( - leading: const Icon(Icons.sms_outlined), - title: Text("Private Nachricht an '${bubbleData.actorDisplayName}'"), - onTap: () => Navigator.of(dialogCtx).pop(), - ), - if (canDelete) - AsyncListTile( - leading: const Icon(Icons.delete_outline), - title: const Text('Nachricht löschen'), - onPressed: () async { - await DeleteMessage(chatData.token, bubbleData.id).run(); - if (dialogCtx.mounted) dialogCtx.read().refresh(); - }, - ), - DebugTile(dialogCtx).jsonData(bubbleData.toJson()), - ], - ), + showDetailsBottomSheet( + context, + children: (sheetCtx) => [ + if (canReact) + _ReactionsRow( + chatToken: chatData.token, + messageId: bubbleData.id, + onRefetch: onRefetch, + sheetContext: sheetCtx, + ), + if (bubbleData.isReplyable) + ListTile( + leading: const Icon(Icons.reply_outlined), + title: const Text('Antworten'), + onTap: () { + sheetCtx.read().setReferenceMessageId(bubbleData.id); + Navigator.of(sheetCtx).pop(); + }, + ), + if (canReact) + ListTile( + leading: const Icon(Icons.emoji_emotions_outlined), + title: const Text('Reaktionen'), + onTap: () { + Navigator.of(sheetCtx).pop(); + if (!parentContext.mounted) return; + AppRoutes.openMessageReactions(parentContext, chatData.token, bubbleData.id); + }, + ), + if (bubbleData.message != '{file}') + ListTile( + leading: const Icon(Icons.copy), + title: const Text('Nachricht kopieren'), + onTap: () { + copyToClipboard(parentContext, bubbleData.message); + Navigator.of(sheetCtx).pop(); + }, + ), + if (!kReleaseMode && !isSender && chatData.type != GetRoomResponseObjectConversationType.oneToOne) + ListTile( + leading: const Icon(Icons.sms_outlined), + title: Text("Private Nachricht an '${bubbleData.actorDisplayName}'"), + onTap: () => Navigator.of(sheetCtx).pop(), + ), + if (canDelete) + AsyncListTile( + leading: const Icon(Icons.delete_outline), + title: const Text('Nachricht löschen'), + onPressed: () async { + await DeleteMessage(chatData.token, bubbleData.id).run(); + if (sheetCtx.mounted) sheetCtx.read().refresh(); + }, + ), + DebugTile(sheetCtx).jsonData(bubbleData.toJson()), + ], ); } @@ -98,13 +97,13 @@ class _ReactionsRow extends StatefulWidget { final String chatToken; final int messageId; final void Function({bool renew}) onRefetch; - final BuildContext dialogContext; + final BuildContext sheetContext; const _ReactionsRow({ required this.chatToken, required this.messageId, required this.onRefetch, - required this.dialogContext, + required this.sheetContext, }); @override @@ -131,7 +130,7 @@ class _ReactionsRowState extends State<_ReactionsRow> { if (!mounted) return; if (ok) { widget.onRefetch(renew: true); - if (widget.dialogContext.mounted) Navigator.of(widget.dialogContext).pop(); + if (widget.sheetContext.mounted) Navigator.of(widget.sheetContext).pop(); } } diff --git a/lib/view/pages/talk/widgets/chat_tile.dart b/lib/view/pages/talk/widgets/chat_tile.dart index c9e8deb..f6a5cab 100644 --- a/lib/view/pages/talk/widgets/chat_tile.dart +++ b/lib/view/pages/talk/widgets/chat_tile.dart @@ -15,6 +15,7 @@ import '../../../../state/app/modules/chat_list/bloc/chat_list_bloc.dart'; import '../../../../widget/async_action_button.dart'; import '../../../../widget/confirm_dialog.dart'; import '../../../../widget/debug/debug_tile.dart'; +import '../../../../widget/details_bottom_sheet.dart'; import '../../../../widget/user_avatar.dart'; import '../chat_view.dart'; import '../talk_navigator.dart'; @@ -124,8 +125,9 @@ class _ChatTileState extends State { }, onLongPress: () { if (widget.disableContextActions) return; - showDialog(context: context, builder: (dialogCtx) => SimpleDialog( - children: [ + showDetailsBottomSheet( + context, + children: (sheetCtx) => [ if (widget.data.unreadMessages > 0) AsyncListTile( leading: const Icon(Icons.mark_chat_read_outlined), @@ -163,7 +165,7 @@ class _ChatTileState extends State { leading: const Icon(Icons.delete_outline), title: const Text('Konversation verlassen'), onTap: () { - Navigator.of(dialogCtx).pop(); + Navigator.of(sheetCtx).pop(); ConfirmDialog( title: 'Chat verlassen', content: 'Du benötigst ggf. eine Einladung um erneut beizutreten.', @@ -175,9 +177,9 @@ class _ChatTileState extends State { ).asDialog(context); }, ), - DebugTile(dialogCtx).jsonData(widget.data.toJson()), + DebugTile(sheetCtx).jsonData(widget.data.toJson()), ], - )); + ); }, ); } diff --git a/lib/view/pages/talk/widgets/poll_options_list.dart b/lib/view/pages/talk/widgets/poll_options_list.dart index 44f4162..a02a320 100644 --- a/lib/view/pages/talk/widgets/poll_options_list.dart +++ b/lib/view/pages/talk/widgets/poll_options_list.dart @@ -29,7 +29,6 @@ class _PollOptionsListState extends State { final portion = numVoters == 0 ? 0.0 : (votes / numVoters); return ListTile( - // enabled: false, isThreeLine: portionsVisible, dense: true, title: Text( diff --git a/lib/view/pages/timetable/data/calendar_layout.dart b/lib/view/pages/timetable/data/calendar_layout.dart index b026edd..aafe4dd 100644 --- a/lib/view/pages/timetable/data/calendar_layout.dart +++ b/lib/view/pages/timetable/data/calendar_layout.dart @@ -3,14 +3,22 @@ const double kCalendarEndHour = 17.25; const Duration kCalendarTimeInterval = Duration(minutes: 30); const double kCalendarViewHeaderHeight = 60; -/// Minimum pixels per hour. Below this, the grid scrolls vertically rather -/// than compressing further. +/// Below this, the grid scrolls vertically rather than compressing further. const double kCalendarMinPxPerHour = 56; -/// Minimum height of a lesson block in the period-based layout. The grid -/// scrolls vertically once lessons would otherwise be smaller than this. +/// The grid scrolls vertically once lessons would otherwise be smaller. const double kLessonBlockMinHeight = 50; -/// Fixed height of a break block in the period-based layout. Independent of -/// the actual break duration; breaks are rendered as a compact indicator. +/// Fixed (independent of actual break duration); breaks render as a compact +/// indicator. 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; diff --git a/lib/view/pages/timetable/widgets/appointment_tile.dart b/lib/view/pages/timetable/widgets/appointment_tile.dart index bb24afb..940b1d0 100644 --- a/lib/view/pages/timetable/widgets/appointment_tile.dart +++ b/lib/view/pages/timetable/widgets/appointment_tile.dart @@ -2,14 +2,11 @@ import 'package:flutter/material.dart'; import 'package:syncfusion_flutter_calendar/calendar.dart'; import '../data/arbitrary_appointment.dart'; +import '../data/calendar_layout.dart'; import 'cross_painter.dart'; class AppointmentTile extends StatelessWidget { 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 bool crossedOut; @@ -42,8 +39,8 @@ class AppointmentTile extends StatelessWidget { children: [ _AdaptiveTitle( text: appointment.subject, - fontSize: _titleFontSize, - minFontSize: _titleMinFontSize, + fontSize: kAppointmentTitleFontSize, + minFontSize: kAppointmentTitleMinFontSize, fontWeight: FontWeight.w500, ), if (isCustom) ...[ @@ -53,8 +50,8 @@ class AppointmentTile extends StatelessWidget { padding: const EdgeInsets.only(top: 1), child: _WrappingBody( text: description, - fontSize: _bodyFontSize, - lineHeight: _bodyLineHeight, + fontSize: kAppointmentBodyFontSize, + lineHeight: kAppointmentBodyLineHeight, ), ), ), @@ -63,7 +60,7 @@ class AppointmentTile extends StatelessWidget { .split('\n') .where((p) => p.isNotEmpty) .take(2)) - _ScaledLine(text: line, fontSize: _bodyFontSize), + _ScaledLine(text: line, fontSize: kAppointmentBodyFontSize), ], ], ), diff --git a/lib/view/pages/timetable/widgets/calendar/outside_chips.dart b/lib/view/pages/timetable/widgets/calendar/outside_chips.dart index 098da70..b861f78 100644 --- a/lib/view/pages/timetable/widgets/calendar/outside_chips.dart +++ b/lib/view/pages/timetable/widgets/calendar/outside_chips.dart @@ -1,11 +1,6 @@ part of '../custom_workweek_calendar.dart'; 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 List appointments; final double rulerWidth; @@ -28,17 +23,17 @@ class _OutsideHoursStrip extends StatelessWidget { final theme = Theme.of(context); final maxChipsPerDay = outside - .map((day) => day.length > _maxVisibleChips ? _maxVisibleChips : day.length) + .map((day) => day.length > kOutsideChipsMaxVisible ? kOutsideChipsMaxVisible : day.length) .fold(0, (m, c) => c > m ? c : m); - final stripHeight = _verticalPadding * 2 + - maxChipsPerDay * _chipHeight + - (maxChipsPerDay > 1 ? (maxChipsPerDay - 1) * _chipSpacing : 0); + final stripHeight = kOutsideStripVerticalPadding * 2 + + maxChipsPerDay * kOutsideChipHeight + + (maxChipsPerDay > 1 ? (maxChipsPerDay - 1) * kOutsideChipSpacing : 0); return Container( color: theme.colorScheme.surfaceContainerLowest, - padding: const EdgeInsets.symmetric(vertical: _verticalPadding), + padding: const EdgeInsets.symmetric(vertical: kOutsideStripVerticalPadding), child: SizedBox( - height: stripHeight - _verticalPadding * 2, + height: stripHeight - kOutsideStripVerticalPadding * 2, child: Row( crossAxisAlignment: CrossAxisAlignment.start, children: [ @@ -47,9 +42,6 @@ class _OutsideHoursStrip extends StatelessWidget { Expanded( child: _OutsideDayColumn( appointments: outside[d], - maxVisible: _maxVisibleChips, - chipHeight: _chipHeight, - chipSpacing: _chipSpacing, onAppointmentTap: onAppointmentTap, isCrossedOut: isCrossedOut, ), @@ -63,17 +55,11 @@ class _OutsideHoursStrip extends StatelessWidget { class _OutsideDayColumn extends StatelessWidget { final List appointments; - final int maxVisible; - final double chipHeight; - final double chipSpacing; final void Function(Appointment) onAppointmentTap; final bool Function(Appointment) isCrossedOut; const _OutsideDayColumn({ required this.appointments, - required this.maxVisible, - required this.chipHeight, - required this.chipSpacing, required this.onAppointmentTap, required this.isCrossedOut, }); @@ -132,11 +118,12 @@ class _OutsideDayColumn extends StatelessWidget { if (!aLike && bLike) return 1; return a.startTime.compareTo(b.startTime); }); - final visible = sorted.length <= maxVisible + final visible = sorted.length <= kOutsideChipsMaxVisible ? sorted - : sorted.take(maxVisible - 1).toList(); - final overflow = - sorted.length <= maxVisible ? const [] : sorted.skip(maxVisible - 1).toList(); + : sorted.take(kOutsideChipsMaxVisible - 1).toList(); + final overflow = sorted.length <= kOutsideChipsMaxVisible + ? const [] + : sorted.skip(kOutsideChipsMaxVisible - 1).toList(); return Padding( padding: const EdgeInsets.symmetric(horizontal: 2), @@ -145,9 +132,9 @@ class _OutsideDayColumn extends StatelessWidget { crossAxisAlignment: CrossAxisAlignment.stretch, children: [ for (var i = 0; i < visible.length; i++) ...[ - if (i > 0) SizedBox(height: chipSpacing), + if (i > 0) const SizedBox(height: kOutsideChipSpacing), SizedBox( - height: chipHeight, + height: kOutsideChipHeight, child: _OutsideChip( appointment: visible[i], onTap: () => onAppointmentTap(visible[i]), @@ -155,9 +142,9 @@ class _OutsideDayColumn extends StatelessWidget { ), ], if (overflow.isNotEmpty) ...[ - SizedBox(height: chipSpacing), + const SizedBox(height: kOutsideChipSpacing), SizedBox( - height: chipHeight, + height: kOutsideChipHeight, child: _OutsideOverflowChip( count: overflow.length, onTap: () => _showOverflow(context, overflow), diff --git a/lib/view/pages/timetable/widgets/calendar/week_grid.dart b/lib/view/pages/timetable/widgets/calendar/week_grid.dart index df4741f..4cb7762 100644 --- a/lib/view/pages/timetable/widgets/calendar/week_grid.dart +++ b/lib/view/pages/timetable/widgets/calendar/week_grid.dart @@ -429,8 +429,7 @@ class _OverflowTile extends StatelessWidget { padding: const EdgeInsets.all(1), child: Stack( children: [ - // Card peeking out at the bottom — visual hint that more cards lie - // underneath the visible one. + // Stacked-cards effect: a darker layer peeks out below the front card. Positioned( top: 4, left: 2, @@ -443,7 +442,6 @@ class _OverflowTile extends StatelessWidget { ), ), ), - // Front card with the "+N" indicator. Positioned( top: 0, left: 0, diff --git a/lib/widget/async_actions/async_text_button.dart b/lib/widget/async_actions/async_text_button.dart index 2938089..cf662f6 100644 --- a/lib/widget/async_actions/async_text_button.dart +++ b/lib/widget/async_actions/async_text_button.dart @@ -40,10 +40,9 @@ class AsyncTextButton extends StatelessWidget { ], ) : child; - return _InlineErrorWrapper( - controller: controller, - child: TextButton(onPressed: handler, child: content), - ); + final button = TextButton(onPressed: handler, child: content); + if (!showInlineError) return button; + return _InlineErrorWrapper(controller: controller, child: button); }, ); } diff --git a/lib/widget/file_viewer.dart b/lib/widget/file_viewer.dart index 4f4e1a2..80c5664 100644 --- a/lib/widget/file_viewer.dart +++ b/lib/widget/file_viewer.dart @@ -81,7 +81,7 @@ class _DeferredPdfViewerState extends State<_DeferredPdfViewer> { } class _FileViewerState extends State { - PhotoViewController photoViewController = PhotoViewController(); + final PhotoViewController photoViewController = PhotoViewController(); late SettingsCubit settings = context.read(); late bool openExternal; @@ -92,6 +92,12 @@ class _FileViewerState extends State { super.initState(); } + @override + void dispose() { + photoViewController.dispose(); + super.dispose(); + } + @override Widget build(BuildContext context) { AppBar appbar({List actions = const []}) => AppBar( @@ -195,10 +201,8 @@ class _FileViewerState extends State { OpenFilex.open(widget.path).then((result) { if (!context.mounted) return; Navigator.of(context).pop(); - if(result.type != ResultType.done) { - showDialog(context: context, builder: (context) => AlertDialog( - content: Text(result.message), - )); + if (result.type != ResultType.done) { + InfoDialog.show(context, result.message); } }); diff --git a/test/api/errors/error_mapper_test.dart b/test/api/errors/error_mapper_test.dart index 2f8bab8..48a4851 100644 --- a/test/api/errors/error_mapper_test.dart +++ b/test/api/errors/error_mapper_test.dart @@ -1,6 +1,7 @@ import 'dart:async'; import 'dart:io'; +import 'package:dio/dio.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:http/http.dart' as http; import 'package:marianum_mobile/api/api_error.dart'; @@ -36,8 +37,9 @@ void main() { }); test('HandshakeException maps to a TLS-specific message', () { - expect(errorToUserMessage(const HandshakeException('bad cert')), - 'Sichere Verbindung konnte nicht hergestellt werden.'); + final message = errorToUserMessage(const HandshakeException('bad cert')); + expect(message, contains('sichere Verbindung')); + expect(message, contains('Geräte-Uhrzeit')); }); test('FormatException maps to ParseException message', () { @@ -63,6 +65,34 @@ void main() { test('custom fallback overrides the default', () { 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', () {