From ee1240f71a961317bb79491ba1edfe3b7d6cf39c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Elias=20M=C3=BCller?= Date: Sun, 27 Sep 2026 17:29:54 +0200 Subject: [PATCH] simplified code across the app and consolidated duplicated helpers --- lib/api/errors/error_mapper.dart | 26 +-- lib/api/http_errors.dart | 28 ++- .../app_password/delete_app_password.dart | 8 +- .../app_password/get_app_password.dart | 20 +- .../cloud_users/cloud_users_actions.dart | 4 +- .../login_flow/login_flow_api.dart | 14 +- lib/api/marianumcloud/nextcloud_ocs.dart | 7 +- .../talk/chat/fetch_chat_page.dart | 46 ++++ lib/api/marianumcloud/talk/chat/get_chat.dart | 10 - .../talk/chat/get_chat_history.dart | 54 ++--- .../talk/chat/long_poll_chat.dart | 47 +--- .../get_participants/get_participants.dart | 9 - .../talk/get_poll/get_poll_state.dart | 9 - .../talk/get_reactions/get_reactions.dart | 11 - .../get_shared_items/get_shared_items.dart | 9 - .../get_shared_items_overview.dart | 9 - lib/api/marianumcloud/talk/room/get_room.dart | 8 - lib/api/marianumcloud/talk/talk_api.dart | 3 +- .../marianumconnect/auth/token_storage.dart | 6 +- lib/api/marianumconnect/client_platform.dart | 8 + .../get_ticker_page/get_ticker_page.dart | 26 +-- .../client_error_reporter.dart | 10 +- .../submit_feedback/submit_feedback.dart | 9 +- lib/app.dart | 65 +++--- lib/background/widget_background_task.dart | 40 +--- lib/extensions/date_time.dart | 4 +- lib/notification/notification_controller.dart | 2 +- lib/notification/notification_tasks.dart | 6 +- lib/push/chat_thread_store.dart | 29 +-- lib/push/push_actions.dart | 17 +- lib/push/push_avatar.dart | 9 +- lib/push/push_message_handler.dart | 2 - lib/push/push_registration.dart | 3 +- lib/push/push_renderer.dart | 49 +++-- lib/push/push_tap_router.dart | 12 +- lib/push/push_target.dart | 13 ++ lib/routing/app_routes.dart | 48 ++-- lib/share_intent/pending_share.dart | 1 - .../view/loadable_state_consumer.dart | 2 +- .../view/loadable_state_error_bar.dart | 63 +++--- .../loadable_hydrated_bloc.dart | 1 - lib/state/app/modules/app_modules.dart | 38 ++-- .../data_provider/breaker_data_provider.dart | 10 +- .../app/modules/chat/bloc/chat_bloc.dart | 63 +++--- .../app/modules/files/bloc/files_bloc.dart | 28 +-- .../files/repository/files_repository.dart | 7 +- .../bloc/foreign_timetable_bloc.dart | 146 +------------ .../foreign_timetable_data_provider.dart | 61 +----- .../modules/holidays/bloc/holidays_bloc.dart | 24 +- .../bloc/marianum_dates_bloc.dart | 20 +- .../bloc/marianum_message_event.dart | 2 - .../bloc/marianum_message_state.dart | 2 - .../marianum_message_get_messages.dart | 3 +- .../app/modules/ticker/bloc/ticker_event.dart | 2 - .../ticker/bloc/ticker_page_event.dart | 5 +- .../timetable/bloc/timetable_bloc.dart | 161 ++------------ .../timetable/bloc/timetable_state.dart | 10 +- .../timetable/bloc/timetable_week_loader.dart | 138 ++++++++++++ .../timetable_data_provider.dart | 73 ++----- lib/storage/dev_tools_settings.dart | 12 +- lib/storage/modules_settings.dart | 4 +- lib/storage/timetable_favorites_settings.dart | 3 +- lib/storage/timetable_settings.dart | 2 +- lib/theming/app_theme.dart | 38 ++-- lib/utils/downloads/download_job.dart | 11 +- lib/utils/downloads/download_manager.dart | 10 +- lib/utils/file_clipboard.dart | 15 +- lib/utils/random_id.dart | 9 - lib/view/login/widgets/login_card.dart | 16 +- .../absence_report/absence_report_view.dart | 22 +- lib/view/pages/files/data/file_sort.dart | 25 +++ lib/view/pages/files/data/file_type_icon.dart | 7 +- lib/view/pages/files/data/webdav_path.dart | 26 +++ lib/view/pages/files/files.dart | 56 +---- lib/view/pages/files/files_upload_dialog.dart | 138 ++++++------ .../files/search/files_search_controller.dart | 39 ++-- .../files/search/files_search_delegate.dart | 8 +- .../files/search/files_search_results.dart | 30 +-- .../files/search/local_cache_search.dart | 9 +- .../files/sharing/share_options_sheet.dart | 71 +++--- lib/view/pages/files/sharing/share_sheet.dart | 23 +- .../pages/files/widgets/clipboard_banner.dart | 38 +--- .../pages/files/widgets/file_element.dart | 22 +- .../files/widgets/files_sort_actions.dart | 26 +-- .../element_picker_page.dart | 32 +-- .../foreign_timetable/element_type_icon.dart | 12 + .../grade_averages/grade_averages_view.dart | 6 +- lib/view/pages/holidays/holidays_view.dart | 2 +- .../marianum_dates/marianum_dates_view.dart | 14 +- lib/view/pages/overhang.dart | 30 +-- .../chat_background_settings_page.dart | 8 +- .../pages/settings/modules_settings_page.dart | 8 +- .../settings/sections/about_section.dart | 9 +- .../settings/sections/account_section.dart | 13 +- .../settings/sections/appearance_section.dart | 30 +-- .../settings/sections/dev_tools_section.dart | 29 +-- .../pages/settings/widgets/privacy_info.dart | 8 +- .../settings/widgets/push_status_sheet.dart | 6 +- .../pages/share_intent/share_chat_picker.dart | 13 +- .../share_intent/share_folder_picker.dart | 67 ++---- lib/view/pages/talk/chat_view.dart | 101 ++++----- .../pages/talk/data/chat_bubble_styles.dart | 6 +- lib/view/pages/talk/data/chat_message.dart | 45 ++-- lib/view/pages/talk/data/talk_markdown.dart | 4 +- lib/view/pages/talk/details/chat_info.dart | 32 +-- .../talk/details/participants_list_view.dart | 4 +- .../pages/talk/details/shared_items_view.dart | 15 +- lib/view/pages/talk/search_chat.dart | 6 +- lib/view/pages/talk/widgets/bubble.dart | 56 ++--- lib/view/pages/talk/widgets/chat_bubble.dart | 73 +++---- .../pages/talk/widgets/chat_bubble_poll.dart | 22 +- .../talk/widgets/chat_bubble_reactions.dart | 4 +- .../widgets/chat_message_options_dialog.dart | 6 +- .../pages/talk/widgets/chat_textfield.dart | 65 +++--- lib/view/pages/talk/widgets/chat_tile.dart | 50 ++--- .../talk/widgets/highlighted_linkify.dart | 8 +- .../pages/talk/widgets/poll_options_list.dart | 22 +- lib/view/pages/talk/widgets/room_actions.dart | 48 ++++ lib/view/pages/ticker/ticker_view.dart | 45 ++-- .../ticker/widgets/ticker_page_body.dart | 4 +- .../custom_event_edit_dialog.dart | 25 +-- .../pages/timetable/data/calendar_logic.dart | 10 +- .../data/timetable_appointment_factory.dart | 15 +- .../timetable/details/custom_event_sheet.dart | 7 +- .../details/delete_custom_event.dart | 7 +- .../pages/timetable/details/lesson_sheet.dart | 68 +----- lib/view/pages/timetable/timetable.dart | 205 +++++++----------- .../timetable/widgets/calendar/week_grid.dart | 16 +- .../widgets/custom_workweek_calendar.dart | 4 +- .../widgets/special_regions_builder.dart | 18 +- .../widgets/timetable_calendar_view.dart | 37 ++-- .../async_actions/async_action_button.dart | 18 +- .../async_action_controller.dart | 21 +- .../async_actions/async_dialog_action.dart | 14 +- lib/widget/async_actions/async_mixin.dart | 20 +- .../async_actions/async_text_button.dart | 14 +- lib/widget/avatar_actions_sheet.dart | 42 +--- lib/widget/avatar_disk_cache.dart | 36 +-- lib/widget/chat_background_picker_sheet.dart | 35 +-- lib/widget/confirm_dialog.dart | 17 +- lib/widget/downloads/download_tray.dart | 6 +- lib/widget/downloads/downloads_sheet.dart | 19 +- .../downloads/stale_download_guard.dart | 57 +++-- lib/widget/file_pick.dart | 50 ++++- lib/widget/file_viewer.dart | 75 +++---- lib/widget/info_dialog.dart | 12 + lib/widget/list_view_util.dart | 3 +- lib/widget/prosemirror/pm_image_view.dart | 21 +- lib/widget/prosemirror/pm_node.dart | 9 + lib/widget/prosemirror/pm_rich_text.dart | 25 +-- lib/widget/prosemirror/pm_table_view.dart | 2 +- lib/widget/user_avatar.dart | 69 +++--- lib/widget_data/widget_data_mapper.dart | 154 +++++++------ lib/widget_data/widget_publisher.dart | 33 +-- lib/widget_data/widget_sync.dart | 51 +++-- 155 files changed, 1772 insertions(+), 2546 deletions(-) create mode 100644 lib/api/marianumcloud/talk/chat/fetch_chat_page.dart create mode 100644 lib/api/marianumconnect/client_platform.dart create mode 100644 lib/state/app/modules/timetable/bloc/timetable_week_loader.dart create mode 100644 lib/view/pages/files/data/file_sort.dart create mode 100644 lib/view/pages/files/data/webdav_path.dart create mode 100644 lib/view/pages/foreign_timetable/element_type_icon.dart create mode 100644 lib/view/pages/talk/widgets/room_actions.dart diff --git a/lib/api/errors/error_mapper.dart b/lib/api/errors/error_mapper.dart index 0722b1a..1925c1a 100644 --- a/lib/api/errors/error_mapper.dart +++ b/lib/api/errors/error_mapper.dart @@ -10,9 +10,7 @@ import '../api_error.dart'; import '../http_errors.dart'; import '../marianumcloud/talk/talk_error.dart'; import 'app_exception.dart'; -import 'auth_exception.dart'; import 'network_exception.dart'; -import 'not_found_exception.dart'; import 'parse_exception.dart'; import 'server_exception.dart'; import 'talk_exception.dart'; @@ -65,23 +63,15 @@ AppException _dynamiteToAppException(DynamiteApiException error) { final detail = preview.isEmpty ? 'HTTP $status' : 'HTTP $status body=$preview'; - switch (status) { - case 401: - return AuthException.unauthorized(technicalDetails: detail); - case 403: - return AuthException.forbidden(technicalDetails: detail); - case 404: - return NotFoundException(technicalDetails: detail); - case 429: - return ServerException( - statusCode: status, - userMessage: - 'Zu viele Anfragen. Bitte warte einen Moment, bevor du es erneut versuchst.', - technicalDetails: detail, - ); - default: - return ServerException(statusCode: status, technicalDetails: detail); + if (status == 429) { + return ServerException( + statusCode: status, + userMessage: + 'Zu viele Anfragen. Bitte warte einen Moment, bevor du es erneut versuchst.', + technicalDetails: detail, + ); } + return exceptionForStatus(status, detail); } String errorToUserMessage(Object? error, {String fallback = _defaultFallback}) { diff --git a/lib/api/http_errors.dart b/lib/api/http_errors.dart index f45f770..0b2caa9 100644 --- a/lib/api/http_errors.dart +++ b/lib/api/http_errors.dart @@ -3,6 +3,7 @@ import 'dart:io'; import 'package:http/http.dart' as http; +import 'errors/app_exception.dart'; import 'errors/auth_exception.dart'; import 'errors/network_exception.dart'; import 'errors/not_found_exception.dart'; @@ -42,12 +43,23 @@ String httpErrorDetail(String label, String body, int status) { : '$label -> HTTP $status body=$preview'; } -/// Throws the [AppException] matching a non-2xx HTTP [status], carrying -/// [detail] as technical details: 401/403 map to auth errors, 404 to -/// not-found, everything else to a generic server error. -Never throwForStatus(int status, String detail) { - if (status == 401) throw AuthException.unauthorized(technicalDetails: detail); - if (status == 403) throw AuthException.forbidden(technicalDetails: detail); - if (status == 404) throw NotFoundException(technicalDetails: detail); - throw ServerException(statusCode: status, technicalDetails: detail); +/// The [AppException] matching a non-2xx HTTP [status], carrying [detail] as +/// technical details: 401/403 map to auth errors, 404 to not-found, everything +/// else to a generic server error. +AppException exceptionForStatus(int status, String detail) => switch (status) { + 401 => AuthException.unauthorized(technicalDetails: detail), + 403 => AuthException.forbidden(technicalDetails: detail), + 404 => NotFoundException(technicalDetails: detail), + _ => ServerException(statusCode: status, technicalDetails: detail), +}; + +/// Throws [exceptionForStatus]. +Never throwForStatus(int status, String detail) => + throw exceptionForStatus(status, detail); + +/// Throws [exceptionForStatus] unless [response] is 2xx. +void ensureSuccess(String label, http.Response response) { + final status = response.statusCode; + if (status >= 200 && status < 300) return; + throwForStatus(status, httpErrorDetail(label, response.body, status)); } diff --git a/lib/api/marianumcloud/app_password/delete_app_password.dart b/lib/api/marianumcloud/app_password/delete_app_password.dart index 3726c86..670212f 100644 --- a/lib/api/marianumcloud/app_password/delete_app_password.dart +++ b/lib/api/marianumcloud/app_password/delete_app_password.dart @@ -18,13 +18,7 @@ class DeleteAppPassword { NextcloudOcs.uri('core/apppassword'), // An explicit header may belong to an inactive account, so the active // session's headers must not be required then. - headers: authorizationHeader == null - ? NextcloudOcs.headers() - : { - 'Accept': 'application/json', - 'OCS-APIRequest': 'true', - 'Authorization': authorizationHeader, - }, + headers: NextcloudOcs.headers(authorization: authorizationHeader), ); } } diff --git a/lib/api/marianumcloud/app_password/get_app_password.dart b/lib/api/marianumcloud/app_password/get_app_password.dart index eaa4005..0e12a33 100644 --- a/lib/api/marianumcloud/app_password/get_app_password.dart +++ b/lib/api/marianumcloud/app_password/get_app_password.dart @@ -28,23 +28,17 @@ class GetAppPassword { label, () => _client.get( NextcloudOcs.uri('core/getapppassword'), - headers: { - ...NextcloudOcs.headers(), - // Deliberately NOT the shared Authorization value: that one prefers - // the app password, but an app password cannot mint another one — - // this endpoint requires the real password. - 'Authorization': SessionManager() + // Deliberately NOT the shared Authorization value: that one prefers + // the app password, but an app password cannot mint another one — + // this endpoint requires the real password. + headers: NextcloudOcs.headers( + authorization: SessionManager() .requireNextcloud() .realPasswordBasicAuthHeader, - }, + ), ), ))!; - if (response.statusCode < 200 || response.statusCode >= 300) { - throwForStatus( - response.statusCode, - httpErrorDetail(label, response.body, response.statusCode), - ); - } + ensureSuccess(label, response); final json = jsonDecode(utf8.decode(response.bodyBytes)); final data = (json as Map)['ocs']?['data']; final appPassword = data is Map ? data['apppassword'] as String? : null; diff --git a/lib/api/marianumcloud/cloud_users/cloud_users_actions.dart b/lib/api/marianumcloud/cloud_users/cloud_users_actions.dart index 847b6f3..36f98d9 100644 --- a/lib/api/marianumcloud/cloud_users/cloud_users_actions.dart +++ b/lib/api/marianumcloud/cloud_users/cloud_users_actions.dart @@ -1,4 +1,3 @@ -import 'dart:convert'; import 'dart:developer'; import 'dart:typed_data'; @@ -96,9 +95,8 @@ class GetUserInfo { uri, ); try { - final root = jsonDecode(response.body) as Map; final data = - (root['ocs'] as Map)['data'] as Map; + NextcloudOcs.decode(response.body)['data'] as Map; return CloudUserInfo( userId: data['id'] as String, displayName: (data['displayname'] as String?) ?? '', diff --git a/lib/api/marianumcloud/login_flow/login_flow_api.dart b/lib/api/marianumcloud/login_flow/login_flow_api.dart index 49ff413..3d9209d 100644 --- a/lib/api/marianumcloud/login_flow/login_flow_api.dart +++ b/lib/api/marianumcloud/login_flow/login_flow_api.dart @@ -29,12 +29,7 @@ class LoginFlowApi { headers: {'Accept': 'application/json', 'User-Agent': userAgent}, ), ))!; - if (response.statusCode < 200 || response.statusCode >= 300) { - throwForStatus( - response.statusCode, - httpErrorDetail(label, response.body, response.statusCode), - ); - } + ensureSuccess(label, response); return LoginFlowInit.fromJson( jsonDecode(utf8.decode(response.bodyBytes)) as Map, ); @@ -53,12 +48,7 @@ class LoginFlowApi { ), ))!; if (response.statusCode == 404) return null; - if (response.statusCode < 200 || response.statusCode >= 300) { - throwForStatus( - response.statusCode, - httpErrorDetail(label, response.body, response.statusCode), - ); - } + ensureSuccess(label, response); return LoginFlowCredentials.fromJson( jsonDecode(utf8.decode(response.bodyBytes)) as Map, ); diff --git a/lib/api/marianumcloud/nextcloud_ocs.dart b/lib/api/marianumcloud/nextcloud_ocs.dart index b127465..4f1817e 100644 --- a/lib/api/marianumcloud/nextcloud_ocs.dart +++ b/lib/api/marianumcloud/nextcloud_ocs.dart @@ -13,10 +13,13 @@ class NextcloudOcs { static Map decode(String raw) => (jsonDecode(raw) as Map)['ocs'] as Map; - static Map headers() => { + /// [authorization] overrides the active session's credentials, which are + /// then not required. + static Map headers({String? authorization}) => { 'Accept': 'application/json', 'OCS-APIRequest': 'true', - 'Authorization': SessionManager().requireNextcloud().basicAuthHeader, + 'Authorization': + authorization ?? SessionManager().requireNextcloud().basicAuthHeader, }; static Uri uri(String pathSuffix, {Map? queryParameters}) { diff --git a/lib/api/marianumcloud/talk/chat/fetch_chat_page.dart b/lib/api/marianumcloud/talk/chat/fetch_chat_page.dart new file mode 100644 index 0000000..1b1e309 --- /dev/null +++ b/lib/api/marianumcloud/talk/chat/fetch_chat_page.dart @@ -0,0 +1,46 @@ +import 'package:flutter/foundation.dart'; +import 'package:http/http.dart' as http; + +import '../../../errors/server_exception.dart'; +import '../../../http_errors.dart'; +import '../../nextcloud_ocs.dart'; +import 'get_chat_params.dart'; +import 'get_chat_response.dart'; + +/// GETs a chat page outside [TalkApi], which treats non-2xx as errors: here +/// HTTP 304 (nothing new / no older messages) is a normal outcome and yields +/// `null`. +Future fetchChatPage({ + required String label, + required String chatToken, + required GetChatParams params, + Duration? timeout, + bool parseOffIsolate = false, +}) async { + final uri = NextcloudOcs.uri( + 'apps/spreed/api/v1/chat/$chatToken', + queryParameters: params.toJson(), + ); + final headers = NextcloudOcs.headers(); + + final response = (await sendGuarded('$label $uri', () { + final request = http.get(uri, headers: headers); + return timeout == null ? request : request.timeout(timeout); + }))!; + + final status = response.statusCode; + if (status == 304) return null; + if (status >= 200 && status < 300) { + final parsed = parseOffIsolate + ? await compute(_parseChatResponse, response.body) + : _parseChatResponse(response.body); + return parsed..headers = response.headers; + } + throw ServerException( + statusCode: status, + technicalDetails: '$label $uri: HTTP $status', + ); +} + +GetChatResponse _parseChatResponse(String body) => + GetChatResponse.fromJson(NextcloudOcs.decode(body)); diff --git a/lib/api/marianumcloud/talk/chat/get_chat.dart b/lib/api/marianumcloud/talk/chat/get_chat.dart index 9268b62..ea2be1a 100644 --- a/lib/api/marianumcloud/talk/chat/get_chat.dart +++ b/lib/api/marianumcloud/talk/chat/get_chat.dart @@ -1,6 +1,3 @@ -import 'package:http/http.dart' as http; -import 'package:http/http.dart'; - import '../../nextcloud_ocs.dart'; import '../talk_api.dart'; import 'get_chat_params.dart'; @@ -16,11 +13,4 @@ class GetChat extends TalkApi { @override GetChatResponse assemble(String raw) => GetChatResponse.fromJson(NextcloudOcs.decode(raw)); - - @override - Future request( - Uri uri, - Object? body, - Map? headers, - ) => http.get(uri, headers: headers); } diff --git a/lib/api/marianumcloud/talk/chat/get_chat_history.dart b/lib/api/marianumcloud/talk/chat/get_chat_history.dart index dad1d84..366c761 100644 --- a/lib/api/marianumcloud/talk/chat/get_chat_history.dart +++ b/lib/api/marianumcloud/talk/chat/get_chat_history.dart @@ -1,17 +1,12 @@ -import 'package:flutter/foundation.dart'; -import 'package:http/http.dart' as http; - -import '../../../errors/server_exception.dart'; -import '../../../http_errors.dart'; -import '../../nextcloud_ocs.dart'; +import 'fetch_chat_page.dart'; import 'get_chat_params.dart'; import 'get_chat_response.dart'; /// Backwards-paging variant of GetChat (`lookIntoFuture=0` + `lastKnownMessageId`) -/// that fetches the page of messages *older* than a given id. Bypasses [TalkApi] -/// because that layer treats non-2xx as errors, and the server answers HTTP 304 -/// when there are no older messages left — a normal "start of chat" outcome here. -/// `setReadMarker=off` so paging into history never moves the read cursor. +/// that fetches the page of messages *older* than a given id. The server +/// answers HTTP 304 when there are no older messages left — a normal "start of +/// chat" outcome (see [fetchChatPage]). `setReadMarker=off` so paging into +/// history never moves the read cursor. class GetChatHistory { final String chatToken; final int lastKnownMessageId; @@ -24,39 +19,18 @@ class GetChatHistory { }); /// Returns the older page, or `null` on HTTP 304 (no older messages). - Future run() async { - final params = GetChatParams( + Future run() => fetchChatPage( + label: 'GetChatHistory', + chatToken: chatToken, + params: GetChatParams( lookIntoFuture: GetChatParamsSwitch.off, lastKnownMessageId: lastKnownMessageId, includeLastKnown: GetChatParamsSwitch.off, setReadMarker: GetChatParamsSwitch.off, limit: limit, - ); - final uri = NextcloudOcs.uri( - 'apps/spreed/api/v1/chat/$chatToken', - queryParameters: params.toJson(), - ); - final headers = NextcloudOcs.headers(); - - final response = (await sendGuarded( - 'GetChatHistory $uri', - () => http.get(uri, headers: headers), - ))!; - - final status = response.statusCode; - if (status == 304) return null; - if (status >= 200 && status < 300) { - // A page holds up to 200 messages and lands while the user is scrolling; - // decoding it on the UI isolate stalls the fling. - final parsed = await compute(_parseChatResponse, response.body); - return parsed..headers = response.headers; - } - throw ServerException( - statusCode: status, - technicalDetails: 'GetChatHistory $uri: HTTP $status', - ); - } + ), + // A page holds up to 200 messages and lands while the user is scrolling; + // decoding it on the UI isolate stalls the fling. + parseOffIsolate: true, + ); } - -GetChatResponse _parseChatResponse(String body) => - GetChatResponse.fromJson(NextcloudOcs.decode(body)); diff --git a/lib/api/marianumcloud/talk/chat/long_poll_chat.dart b/lib/api/marianumcloud/talk/chat/long_poll_chat.dart index 6b285b1..43adeb8 100644 --- a/lib/api/marianumcloud/talk/chat/long_poll_chat.dart +++ b/lib/api/marianumcloud/talk/chat/long_poll_chat.dart @@ -1,15 +1,10 @@ -import 'package:http/http.dart' as http; - -import '../../../errors/server_exception.dart'; -import '../../../http_errors.dart'; -import '../../nextcloud_ocs.dart'; +import 'fetch_chat_page.dart'; import 'get_chat_params.dart'; import 'get_chat_response.dart'; -/// Long-poll variant of GetChat (`lookIntoFuture=1`). Bypasses [TalkApi] -/// because that layer treats non-2xx as errors, and we need 304 to be a -/// normal "no new messages" outcome. `setReadMarker=on` lets the server -/// move the read cursor whenever the call returns messages. +/// Long-poll variant of GetChat (`lookIntoFuture=1`); HTTP 304 is the normal +/// "no new messages" outcome (see [fetchChatPage]). `setReadMarker=on` lets +/// the server move the read cursor whenever the call returns messages. class LongPollChat { final String chatToken; final int lastKnownMessageId; @@ -22,37 +17,17 @@ class LongPollChat { }); /// Returns the response, or `null` on HTTP 304 (server timeout, nothing new). - Future run() async { - final params = GetChatParams( + Future run() => fetchChatPage( + label: 'LongPollChat', + chatToken: chatToken, + params: GetChatParams( lookIntoFuture: GetChatParamsSwitch.on, timeout: timeoutSeconds, lastKnownMessageId: lastKnownMessageId, includeLastKnown: GetChatParamsSwitch.off, setReadMarker: GetChatParamsSwitch.on, limit: 100, - ); - final uri = NextcloudOcs.uri( - 'apps/spreed/api/v1/chat/$chatToken', - queryParameters: params.toJson(), - ); - final headers = NextcloudOcs.headers(); - - final response = (await sendGuarded( - 'LongPollChat $uri', - () => http - .get(uri, headers: headers) - .timeout(Duration(seconds: timeoutSeconds + 15)), - ))!; - - final status = response.statusCode; - if (status == 304) return null; - if (status >= 200 && status < 300) { - return GetChatResponse.fromJson(NextcloudOcs.decode(response.body)) - ..headers = response.headers; - } - throw ServerException( - statusCode: status, - technicalDetails: 'LongPollChat $uri: HTTP $status', - ); - } + ), + timeout: Duration(seconds: timeoutSeconds + 15), + ); } diff --git a/lib/api/marianumcloud/talk/get_participants/get_participants.dart b/lib/api/marianumcloud/talk/get_participants/get_participants.dart index b44be0b..3dfba40 100644 --- a/lib/api/marianumcloud/talk/get_participants/get_participants.dart +++ b/lib/api/marianumcloud/talk/get_participants/get_participants.dart @@ -1,5 +1,3 @@ -import 'package:http/http.dart' as http; - import '../../nextcloud_ocs.dart'; import '../talk_api.dart'; import 'get_participants_response.dart'; @@ -11,11 +9,4 @@ class GetParticipants extends TalkApi { @override GetParticipantsResponse assemble(String raw) => GetParticipantsResponse.fromJson(NextcloudOcs.decode(raw)); - - @override - Future request( - Uri uri, - Object? body, - Map? headers, - ) => http.get(uri, headers: headers); } diff --git a/lib/api/marianumcloud/talk/get_poll/get_poll_state.dart b/lib/api/marianumcloud/talk/get_poll/get_poll_state.dart index 396b8b6..8e19f42 100644 --- a/lib/api/marianumcloud/talk/get_poll/get_poll_state.dart +++ b/lib/api/marianumcloud/talk/get_poll/get_poll_state.dart @@ -1,5 +1,3 @@ -import 'package:http/http.dart' as http; - import '../../nextcloud_ocs.dart'; import '../talk_api.dart'; import 'get_poll_state_response.dart'; @@ -13,11 +11,4 @@ class GetPollState extends TalkApi { @override GetPollStateResponse assemble(String raw) => GetPollStateResponse.fromJson(NextcloudOcs.decode(raw)); - - @override - Future request( - Uri uri, - Object? body, - Map? headers, - ) => http.get(uri, headers: headers); } diff --git a/lib/api/marianumcloud/talk/get_reactions/get_reactions.dart b/lib/api/marianumcloud/talk/get_reactions/get_reactions.dart index e154207..6758234 100644 --- a/lib/api/marianumcloud/talk/get_reactions/get_reactions.dart +++ b/lib/api/marianumcloud/talk/get_reactions/get_reactions.dart @@ -1,7 +1,3 @@ -import 'package:http/http.dart' as http; -import 'package:http/http.dart'; - -import '../../../api_params.dart'; import '../../nextcloud_ocs.dart'; import '../talk_api.dart'; import 'get_reactions_response.dart'; @@ -15,11 +11,4 @@ class GetReactions extends TalkApi { @override GetReactionsResponse assemble(String raw) => GetReactionsResponse.fromJson(NextcloudOcs.decode(raw)); - - @override - Future? request( - Uri uri, - ApiParams? body, - Map? headers, - ) => http.get(uri, headers: headers); } diff --git a/lib/api/marianumcloud/talk/get_shared_items/get_shared_items.dart b/lib/api/marianumcloud/talk/get_shared_items/get_shared_items.dart index 7624d85..b0cc7e8 100644 --- a/lib/api/marianumcloud/talk/get_shared_items/get_shared_items.dart +++ b/lib/api/marianumcloud/talk/get_shared_items/get_shared_items.dart @@ -1,5 +1,3 @@ -import 'package:http/http.dart' as http; - import '../../nextcloud_ocs.dart'; import '../talk_api.dart'; import 'get_shared_items_response.dart'; @@ -30,11 +28,4 @@ class GetSharedItems extends TalkApi { @override GetSharedItemsResponse assemble(String raw) => GetSharedItemsResponse.fromOcs(NextcloudOcs.decode(raw)); - - @override - Future request( - Uri uri, - Object? body, - Map? headers, - ) => http.get(uri, headers: headers); } diff --git a/lib/api/marianumcloud/talk/get_shared_items/get_shared_items_overview.dart b/lib/api/marianumcloud/talk/get_shared_items/get_shared_items_overview.dart index d23a113..02e8eb2 100644 --- a/lib/api/marianumcloud/talk/get_shared_items/get_shared_items_overview.dart +++ b/lib/api/marianumcloud/talk/get_shared_items/get_shared_items_overview.dart @@ -1,5 +1,3 @@ -import 'package:http/http.dart' as http; - import '../../nextcloud_ocs.dart'; import '../talk_api.dart'; import 'get_shared_items_overview_response.dart'; @@ -18,11 +16,4 @@ class GetSharedItemsOverview extends TalkApi { @override GetSharedItemsOverviewResponse assemble(String raw) => GetSharedItemsOverviewResponse.fromOcs(NextcloudOcs.decode(raw)); - - @override - Future request( - Uri uri, - Object? body, - Map? headers, - ) => http.get(uri, headers: headers); } diff --git a/lib/api/marianumcloud/talk/room/get_room.dart b/lib/api/marianumcloud/talk/room/get_room.dart index 8c9d01f..7732ee8 100644 --- a/lib/api/marianumcloud/talk/room/get_room.dart +++ b/lib/api/marianumcloud/talk/room/get_room.dart @@ -1,5 +1,4 @@ import 'package:flutter/foundation.dart'; -import 'package:http/http.dart' as http; import '../../nextcloud_ocs.dart'; import '../talk_api.dart'; @@ -17,13 +16,6 @@ class GetRoom extends TalkApi { @override Future assembleAsync(String raw) => compute(_parseRooms, raw); - - @override - Future request( - Uri uri, - Object? body, - Map? headers, - ) => http.get(uri, headers: headers); } GetRoomResponse _parseRooms(String raw) => diff --git a/lib/api/marianumcloud/talk/talk_api.dart b/lib/api/marianumcloud/talk/talk_api.dart index 64e7e29..2d743e1 100644 --- a/lib/api/marianumcloud/talk/talk_api.dart +++ b/lib/api/marianumcloud/talk/talk_api.dart @@ -23,11 +23,12 @@ abstract class TalkApi { TalkApi(this.path, this.body, {this.headers, this.getParameters}); + /// Defaults to a plain GET; endpoints with a body or another verb override it. Future? request( Uri uri, ApiParams? body, Map? headers, - ); + ) => http.get(uri, headers: headers); T assemble(String raw); /// Override to parse large payloads off the UI isolate (e.g. via `compute` diff --git a/lib/api/marianumconnect/auth/token_storage.dart b/lib/api/marianumconnect/auth/token_storage.dart index 4692dae..048bcec 100644 --- a/lib/api/marianumconnect/auth/token_storage.dart +++ b/lib/api/marianumconnect/auth/token_storage.dart @@ -81,8 +81,8 @@ class MarianumConnectTokenStorage { } Future clear() async { - await _storage.delete(key: _tokenKey); - await _storage.delete(key: _tokenIdKey); - await _storage.delete(key: _expiresAtKey); + for (final key in fieldKeys) { + await _storage.delete(key: key); + } } } diff --git a/lib/api/marianumconnect/client_platform.dart b/lib/api/marianumconnect/client_platform.dart new file mode 100644 index 0000000..651c47c --- /dev/null +++ b/lib/api/marianumconnect/client_platform.dart @@ -0,0 +1,8 @@ +import 'dart:io'; + +/// Platform wire value MarianumConnect expects (`android`/`ios`), null elsewhere. +String? clientPlatform() { + if (Platform.isAndroid) return 'android'; + if (Platform.isIOS) return 'ios'; + return null; +} diff --git a/lib/api/marianumconnect/queries/get_ticker_page/get_ticker_page.dart b/lib/api/marianumconnect/queries/get_ticker_page/get_ticker_page.dart index 60a5ece..9264b25 100644 --- a/lib/api/marianumconnect/queries/get_ticker_page/get_ticker_page.dart +++ b/lib/api/marianumconnect/queries/get_ticker_page/get_ticker_page.dart @@ -2,8 +2,8 @@ import 'dart:convert'; import 'package:dio/dio.dart'; +import '../../../errors/app_exception.dart'; import '../../../errors/ticker_content_unavailable_exception.dart'; -import '../../errors/marianumconnect_error.dart'; import '../../marianumconnect_query.dart'; import 'get_ticker_page_response.dart'; @@ -14,26 +14,20 @@ import 'get_ticker_page_response.dart'; /// 404 `{ "error": "CONTENT_UNAVAILABLE", "webUrl": ... }`; that case is mapped /// to a dedicated [TickerContentUnavailableException] carrying the browser /// fallback URL, so the detail screen can offer "open in browser" instead of a -/// generic error. The bespoke 404 handling is why this keeps its own try/catch -/// instead of the base [guard]. +/// generic error. class GetTickerPage extends MarianumConnectQuery { final String slug; GetTickerPage(this.slug, {super.dio}); - Future run() async { - try { - final response = await dio.get>( - endpoint('ticker/pages/${Uri.encodeComponent(slug)}'), - ); - return TickerPageResponse.fromJson(response.data!); - } on DioException catch (e) { - throw _mapError(e); - } - } + Future run() => getObject( + 'ticker/pages/${Uri.encodeComponent(slug)}', + TickerPageResponse.fromJson, + ); - Object _mapError(DioException e) { - final response = e.response; + @override + AppException mapError(DioException error) { + final response = error.response; if (response?.statusCode == 404) { final body = _asMap(response?.data); if (body != null && body['error'] == 'CONTENT_UNAVAILABLE') { @@ -44,7 +38,7 @@ class GetTickerPage extends MarianumConnectQuery { ); } } - return mapMarianumConnectError(e); + return super.mapError(error); } Map? _asMap(dynamic data) { diff --git a/lib/api/marianumconnect/queries/report_client_error/client_error_reporter.dart b/lib/api/marianumconnect/queries/report_client_error/client_error_reporter.dart index 5c8aa35..45bc93d 100644 --- a/lib/api/marianumconnect/queries/report_client_error/client_error_reporter.dart +++ b/lib/api/marianumconnect/queries/report_client_error/client_error_reporter.dart @@ -1,10 +1,10 @@ import 'dart:async'; import 'dart:developer'; -import 'dart:io'; import 'package:flutter/foundation.dart'; import 'package:package_info_plus/package_info_plus.dart'; +import '../../client_platform.dart'; import 'report_client_error.dart'; /// Fire-and-forget bridge from the app's global error handlers to the @@ -61,7 +61,7 @@ class ClientErrorReporter { message: message, stacktrace: stacktrace, context: context, - platform: _platform(), + platform: clientPlatform(), appVersion: _appVersion, ); } catch (e) { @@ -73,10 +73,4 @@ class ClientErrorReporter { final normalized = message.toLowerCase().replaceAll(RegExp(r'\d+'), '#'); return '$errorType|$normalized|${context ?? ''}'; } - - static String? _platform() { - if (Platform.isAndroid) return 'android'; - if (Platform.isIOS) return 'ios'; - return null; - } } diff --git a/lib/api/marianumconnect/queries/submit_feedback/submit_feedback.dart b/lib/api/marianumconnect/queries/submit_feedback/submit_feedback.dart index 2eeac7e..1eceba6 100644 --- a/lib/api/marianumconnect/queries/submit_feedback/submit_feedback.dart +++ b/lib/api/marianumconnect/queries/submit_feedback/submit_feedback.dart @@ -5,6 +5,7 @@ import 'dart:typed_data'; import 'package:device_info_plus/device_info_plus.dart'; import 'package:package_info_plus/package_info_plus.dart'; +import '../../client_platform.dart'; import '../../marianumconnect_query.dart'; /// Submits user feedback to MarianumConnect (`POST me/feedback`, bearer-auth). @@ -27,7 +28,7 @@ class SubmitFeedback extends MarianumConnectQuery { 'message': message, 'screenshot': ?screenshotBase64, 'screenshotContentType': screenshot != null ? screenshotContentType : null, - 'platform': _platform(), + 'platform': clientPlatform(), 'appVersion': package.version, 'appBuild': int.tryParse(package.buildNumber), 'deviceModel': await _deviceModel(), @@ -35,12 +36,6 @@ class SubmitFeedback extends MarianumConnectQuery { ); }); - static String? _platform() { - if (Platform.isAndroid) return 'android'; - if (Platform.isIOS) return 'ios'; - return null; - } - static Future _deviceModel() async { try { final info = DeviceInfoPlugin(); diff --git a/lib/app.dart b/lib/app.dart index b8a0fc2..f64da58 100644 --- a/lib/app.dart +++ b/lib/app.dart @@ -80,6 +80,25 @@ class _AppState extends State with WidgetsBindingObserver { } } + /// Shares one debounce key, so a later emit supersedes a pending publish. + void _scheduleWidgetPublish(TimetableState data, Duration delay) { + final settingsCubit = context.read(); + final capabilitiesCubit = context.read(); + final epoch = SessionManager().sessionEpoch; + Debouncer.debounce( + 'widgetPublish', + delay, + () => unawaited( + WidgetPublisher.publishFromBlocState( + data, + settings: settingsCubit.val(), + isTeacher: capabilitiesCubit.isTeacher, + epoch: epoch, + ), + ), + ); + } + void _reportTelemetry() { if (!mounted) return; TelemetryHeartbeat.report( @@ -125,13 +144,18 @@ class _AppState extends State with WidgetsBindingObserver { final pending = await WidgetNavigation.consumePendingTimetableTap(); if (!pending || !mounted) return; // `withNavBar: false` routes sit on the root navigator above the - // bottom-nav; pop them so jumpToTab is actually visible. Stop at - // popups so open dialogs/sheets stay alive. + // bottom-nav; pop them so jumpToTab is actually visible. + _popToShell(); + AppRoutes.goToTab(context, Modules.timetable); + } + + /// Pops full-page routes back to the tab shell, stopping at popups so open + /// dialogs/sheets stay alive. + void _popToShell() { final navigator = Navigator.of(context); if (navigator.canPop()) { navigator.popUntil((route) => route.isFirst || route is PopupRoute); } - AppRoutes.goToTab(context, Modules.timetable); } void _handlePendingShare() { @@ -140,10 +164,7 @@ class _AppState extends State with WidgetsBindingObserver { if (share == null || ShareIntentListener.instance.isShown(share)) return; // A second share would otherwise leave the previous share-flow page // on top with stale (already-cleared) file paths. - final navigator = Navigator.of(context); - if (navigator.canPop()) { - navigator.popUntil((route) => route.isFirst || route is PopupRoute); - } + _popToShell(); ShareIntentListener.instance.markShown(share); AppRoutes.openShareTarget(context, share); } @@ -166,27 +187,13 @@ class _AppState extends State with WidgetsBindingObserver { timetable.refresh(); // Mirror BLoC updates into the home-screen widget without waiting // for the periodic background refresh. - final settingsCubit = context.read(); - final capabilitiesCubit = context.read(); _timetableWidgetSync?.cancel(); // Debounced: a refresh or week swipe emits several times in a row, and // only the settled state is worth mirroring. _timetableWidgetSync = timetable.stream.listen((state) { final data = state.data; if (data is TimetableState && !state.isLoading) { - final epoch = SessionManager().sessionEpoch; - Debouncer.debounce( - 'widgetPublish', - const Duration(seconds: 1), - () => unawaited( - WidgetPublisher.publishFromBlocState( - data, - settings: settingsCubit.val(), - isTeacher: capabilitiesCubit.isTeacher, - epoch: epoch, - ), - ), - ); + _scheduleWidgetPublish(data, const Duration(seconds: 1)); } }); // Initial publish in case hydrated storage already has data. The widget @@ -194,19 +201,7 @@ class _AppState extends State with WidgetsBindingObserver { // frames are done (a fresh bloc emit in the meantime supersedes it). final initialData = timetable.state.data; if (initialData is TimetableState) { - final epoch = SessionManager().sessionEpoch; - Debouncer.debounce( - 'widgetPublish', - const Duration(seconds: 3), - () => unawaited( - WidgetPublisher.publishFromBlocState( - initialData, - settings: settingsCubit.val(), - isTeacher: capabilitiesCubit.isTeacher, - epoch: epoch, - ), - ), - ); + _scheduleWidgetPublish(initialData, const Duration(seconds: 3)); } unawaited(_handlePendingWidgetNavigation()); ShareIntentListener.pending.addListener(_handlePendingShare); diff --git a/lib/background/widget_background_task.dart b/lib/background/widget_background_task.dart index 4e75191..550b5ff 100644 --- a/lib/background/widget_background_task.dart +++ b/lib/background/widget_background_task.dart @@ -17,7 +17,6 @@ import '../api/marianumconnect/queries/timetable_get_timegrid/timetable_get_time import '../api/marianumconnect/queries/timetable_get_timegrid/timetable_get_timegrid_response.dart'; import '../api/marianumconnect/queries/timetable_get_week/timetable_get_week.dart'; import '../api/mhsl/custom_timetable_event/get/get_custom_timetable_event_response.dart'; -import '../session/session.dart'; import '../session/session_manager.dart'; import '../widget_data/widget_data_mapper.dart'; import '../widget_data/widget_publisher.dart'; @@ -81,16 +80,12 @@ class WidgetBackgroundTask { /// Throws on fetch failure so the worker path can signal a retry. static Future runRefreshNow({bool force = false}) async { await WidgetSync.ensureInitialized(); - Session? session; - try { - // Bounded: a hanging keystore read must not stall the caller's budget - // (FCM handler ~25s on iOS) forever. - session = await SessionManager().waitForLoad().timeout( - const Duration(seconds: 10), - ); - } on TimeoutException { - session = null; - } + // Bounded: a hanging keystore read must not stall the caller's budget + // (FCM handler ~25s on iOS) forever. + final session = await SessionManager().waitForLoad().timeout( + const Duration(seconds: 10), + onTimeout: () => null, + ); if (session == null) { // Deliberately does NOT flip the widget to logged-out: a failed or slow // keychain read (locked iOS device during the 06:00 silent push) is @@ -143,7 +138,6 @@ void _callbackDispatcher() { } Future _refresh() async { - await WidgetSync.ensureInitialized(); // The background isolate doesn't go through main.dart's BlocBuilder, so we // re-apply the endpoint the foreground last persisted. Without this the // dio singleton would fall back to its hardcoded live default even when @@ -191,26 +185,13 @@ Future _refresh() async { final timegrid = await timegridFuture; final customEvents = await customEventsFuture; - final lessons = timetable.entries; - final [connectDouble, isTeacher] = await Future.wait([ WidgetSync.getConnectDoubleLessons(), WidgetSync.getIsTeacher(), ]); - final dayData = WidgetDataMapper.buildDayData( + final (day: dayData, week: weekData) = WidgetDataMapper.buildSnapshot( now: now, - lessons: lessons, - subjects: subjects, - rooms: rooms, - holidays: holidays, - timegrid: timegrid, - customEvents: customEvents, - connectDoubleLessons: connectDouble, - showClassInsteadOfTeacher: isTeacher, - ); - final weekData = WidgetDataMapper.buildWeekData( - now: now, - lessons: lessons, + lessons: timetable.entries, subjects: subjects, rooms: rooms, holidays: holidays, @@ -220,10 +201,7 @@ Future _refresh() async { showClassInsteadOfTeacher: isTeacher, ); - await WidgetSync.writeDayData(dayData); - await WidgetSync.writeWeekData(weekData); - await WidgetSync.setLoggedIn(true); - await WidgetSync.triggerUpdate(); + await WidgetSync.publishSnapshot(day: dayData, week: weekData); log( '[widget-bg] refreshed: day=${dayData.lessons.length} ' 'week=${weekData.lessons.length}', diff --git a/lib/extensions/date_time.dart b/lib/extensions/date_time.dart index 43fe685..267ff50 100644 --- a/lib/extensions/date_time.dart +++ b/lib/extensions/date_time.dart @@ -72,9 +72,7 @@ extension DateTimeFormatting on DateTime { String formatDateRelativeShort({DateTime? now}) { final reference = now ?? DateTime.now(); - final today = DateTime(reference.year, reference.month, reference.day); - final self = DateTime(year, month, day); - final diff = today.difference(self).inDays; + final diff = reference.dateOnly.difference(dateOnly).inDays; if (diff == 0) return 'Heute'; if (diff == 1) return 'Gestern'; diff --git a/lib/notification/notification_controller.dart b/lib/notification/notification_controller.dart index dd35a00..e63aff3 100644 --- a/lib/notification/notification_controller.dart +++ b/lib/notification/notification_controller.dart @@ -15,7 +15,7 @@ import '../widget/info_dialog.dart'; import 'notification_tasks.dart'; /// Bridges FCM lifecycle callbacks to the push pipeline. Background messages are -/// handled directly by [PushMessageHandler.onBackgroundMessage]; this class +/// handled directly by `pushOnBackgroundMessage`; this class /// covers the foreground and app-opened paths where a [BuildContext] is /// available. class NotificationController { diff --git a/lib/notification/notification_tasks.dart b/lib/notification/notification_tasks.dart index 57d2913..6e7cff8 100644 --- a/lib/notification/notification_tasks.dart +++ b/lib/notification/notification_tasks.dart @@ -27,17 +27,13 @@ class NotificationTasks { } } - /// Per-chat tag scheme — canonical definition lives beside the stacked - /// notification id in chat_thread_store.dart. - static String chatTag(String chatToken) => chatNotificationTag(chatToken); - /// Removes tray notifications belonging to [chatToken]. Eraser handles /// iOS (where the plugin's `getActiveNotifications` returns null ids /// for FCM posts and can't cancel them); the local-notifications sweep /// handles Android and acts as a fallback while Eraser's native side /// isn't built in yet. static Future clearNotificationsForChat(String chatToken) async { - final tag = chatTag(chatToken); + final tag = chatNotificationTag(chatToken); try { await Eraser.clearAppNotificationsByTag(tag); } on MissingPluginException { diff --git a/lib/push/chat_thread_store.dart b/lib/push/chat_thread_store.dart index b61f5a7..8a692c5 100644 --- a/lib/push/chat_thread_store.dart +++ b/lib/push/chat_thread_store.dart @@ -6,13 +6,20 @@ import 'package:localstore/localstore.dart'; /// notification, small enough to stay cheap in the background isolate. const int kChatThreadCap = 15; -/// Canonical per-chat notification tag (kept in sync with -/// `NotificationTasks.chatTag`, which delegates here). +/// Canonical per-chat notification tag. String chatNotificationTag(String chatToken) => 'talk_$chatToken'; /// Deterministic non-negative 31-bit notification id per chat, so every /// message of the same conversation updates ONE stacked notification instead /// of adding a new card per push. +final _fileSafeToken = RegExp(r'^[A-Za-z0-9_-]+$'); + +/// File-/doc-safe name for a chat token: normally URL-safe and used verbatim, +/// anything else is base64url-encoded so exotic ids can't escape a directory. +String fileSafeToken(String chatToken) => _fileSafeToken.hasMatch(chatToken) + ? chatToken + : base64Url.encode(utf8.encode(chatToken)).replaceAll('=', ''); + int stableChatNotificationId(String chatToken) { var hash = 0; for (final unit in chatToken.codeUnits) { @@ -128,12 +135,8 @@ class ChatThreadStore { ChatThreadStore({Localstore? db}) : _db = db ?? Localstore.instance; - /// Localstore doc ids become file names — tokens are normally URL-safe and - /// used verbatim, anything else is base64url-encoded. - static String docIdForToken(String chatToken) => - RegExp(r'^[A-Za-z0-9_-]+$').hasMatch(chatToken) - ? chatToken - : base64Url.encode(utf8.encode(chatToken)).replaceAll('=', ''); + /// Localstore doc ids become file names. + static String docIdForToken(String chatToken) => fileSafeToken(chatToken); Future> messages(String chatToken) async { final data = await _doc(chatToken).get(); @@ -145,16 +148,6 @@ class ChatThreadStore { .toList(); } - /// Appends [message] and returns the updated (capped) history. - Future> append( - String chatToken, - ThreadMessage message, - ) async { - final updated = appendThreadMessage(await messages(chatToken), message); - await _write(chatToken, updated); - return updated; - } - /// Appends [message], but restarts the thread first when the chat's previous /// notification is no longer active ([isActive] == false — dismissed/read). /// See [threadAfterIncoming] for the tri-state semantics. diff --git a/lib/push/push_actions.dart b/lib/push/push_actions.dart index 6da0fe7..3bf6da2 100644 --- a/lib/push/push_actions.dart +++ b/lib/push/push_actions.dart @@ -1,5 +1,4 @@ import 'dart:async'; -import 'dart:convert'; import 'dart:developer'; import 'dart:ui'; @@ -13,6 +12,7 @@ import '../session/session_manager.dart'; import 'chat_thread_store.dart'; import 'nid_store.dart'; import 'push_renderer.dart'; +import 'push_target.dart'; /// Notification action identifiers shared between the renderer (which attaches /// the actions) and the response handlers (which dispatch them). @@ -93,8 +93,7 @@ class PushActions { // the mark-read action does. Best effort: a failure here must not // fail the already-delivered reply. await markRead(chatToken); - } - if (!result.ok) { + } else { // Never swallow the typed message: surface the failure (and the // undelivered text) as its own notification. The nid mapping stays // alive — the thread notification is still in the tray. @@ -287,14 +286,6 @@ class PushActions { return raw == null ? null : int.tryParse(raw); } - static String? _payloadField(String? payload, String key) { - if (payload == null || payload.isEmpty) return null; - try { - final map = jsonDecode(payload) as Map; - final value = map[key]; - return value?.toString(); - } on Object { - return null; - } - } + static String? _payloadField(String? payload, String key) => + decodeNotificationPayload(payload)?[key]?.toString(); } diff --git a/lib/push/push_avatar.dart b/lib/push/push_avatar.dart index 5821ac0..8c937fa 100644 --- a/lib/push/push_avatar.dart +++ b/lib/push/push_avatar.dart @@ -9,6 +9,7 @@ import 'package:http/http.dart' as http; import 'package:path_provider/path_provider.dart'; import '../api/marianumcloud/nextcloud_ocs.dart'; +import 'chat_thread_store.dart'; /// Result of an avatar lookup at render time: [icon] when bytes are available /// within the fetch budget, otherwise [late] carries the still-running fetch @@ -102,13 +103,7 @@ class PushAvatarStore { }, ); - /// File-safe cache name for a chat token. Tokens are normally URL-safe - /// already and used verbatim; anything else is base64url-encoded so exotic - /// ids can never escape the cache directory. - static String fileNameForToken(String token) => - RegExp(r'^[A-Za-z0-9_-]+$').hasMatch(token) - ? token - : base64Url.encode(utf8.encode(token)).replaceAll('=', ''); + static String fileNameForToken(String token) => fileSafeToken(token); /// Whether a cache file written at [modified] is still fresh at [now]. static bool isFresh(DateTime modified, DateTime now) => diff --git a/lib/push/push_message_handler.dart b/lib/push/push_message_handler.dart index 9ac324c..0a2c92a 100644 --- a/lib/push/push_message_handler.dart +++ b/lib/push/push_message_handler.dart @@ -101,7 +101,6 @@ class PushMessageHandler { case PushKind.connect: await _handleConnect( message, - foreground: foreground, notificationsEnabled: notificationsEnabled, ); break; @@ -139,7 +138,6 @@ class PushMessageHandler { Future _handleConnect( RemoteMessage message, { - required bool foreground, required bool notificationsEnabled, }) async { // Connect pushes carry no silent side effects, so nothing to do when the diff --git a/lib/push/push_registration.dart b/lib/push/push_registration.dart index f8a75d4..c141544 100644 --- a/lib/push/push_registration.dart +++ b/lib/push/push_registration.dart @@ -258,10 +258,9 @@ class PushRegistration { /// Flutter engine. Best-effort — a failure here must not abort registration. Future _persistNativeAuthContext() async { try { - final endpoint = EndpointData().nextcloud(); await _store.saveNativeAuthContext( username: SessionManager().requireNextcloud().username, - baseUrl: 'https://${endpoint.full()}', + baseUrl: currentNcBaseUrl, ); } on Object catch (e) { log('Push: could not persist native auth context: $e'); diff --git a/lib/push/push_renderer.dart b/lib/push/push_renderer.dart index 678c90c..e95d8f4 100644 --- a/lib/push/push_renderer.dart +++ b/lib/push/push_renderer.dart @@ -203,16 +203,10 @@ class PushRenderer { messages: styleMessages, ); - final androidDetails = AndroidNotificationDetails( - talkChannelId, - talkChannelName, - importance: Importance.high, - priority: Priority.high, - category: AndroidNotificationCategory.message, - color: _accentColor, + final androidDetails = _talkAndroidDetails( tag: tag, + style: messagingStyle, silent: !alert, - styleInformation: messagingStyle, actions: _talkActions, ); @@ -247,17 +241,11 @@ class PushRenderer { title: senderName, body: messageText, notificationDetails: NotificationDetails( - android: AndroidNotificationDetails( - talkChannelId, - talkChannelName, - importance: Importance.high, - priority: Priority.high, - category: AndroidNotificationCategory.message, - color: _accentColor, + android: _talkAndroidDetails( tag: tag, // Single sender ⇒ no conversationTitle (1:1 convention): the sender // Person is the header, a title would repeat the name per line. - styleInformation: MessagingStyleInformation( + style: MessagingStyleInformation( const Person(key: 'self', name: 'Ich'), groupConversation: false, messages: [ @@ -271,6 +259,25 @@ class PushRenderer { ); } + static AndroidNotificationDetails _talkAndroidDetails({ + required String tag, + required StyleInformation style, + AndroidNotificationCategory? category = AndroidNotificationCategory.message, + bool silent = false, + List? actions, + }) => AndroidNotificationDetails( + talkChannelId, + talkChannelName, + importance: Importance.high, + priority: Priority.high, + category: category, + color: _accentColor, + tag: tag, + silent: silent, + styleInformation: style, + actions: actions, + ); + // Both actions keep cancelNotification: true (the default): the plugin's // Java receiver then removes the card NATIVELY the moment the action fires. // The reply action must not rely on our Dart-side cancel instead — MIUI/ @@ -306,14 +313,10 @@ class PushRenderer { title: title, body: body, notificationDetails: NotificationDetails( - android: AndroidNotificationDetails( - talkChannelId, - talkChannelName, - importance: Importance.high, - priority: Priority.high, - color: _accentColor, + android: _talkAndroidDetails( tag: 'talk_error_$chatToken', - styleInformation: BigTextStyleInformation(body), + style: BigTextStyleInformation(body), + category: null, ), iOS: const DarwinNotificationDetails(), ), diff --git a/lib/push/push_tap_router.dart b/lib/push/push_tap_router.dart index f77102a..2c16caa 100644 --- a/lib/push/push_tap_router.dart +++ b/lib/push/push_tap_router.dart @@ -1,4 +1,3 @@ -import 'dart:convert'; import 'dart:developer'; import 'package:flutter/foundation.dart'; @@ -25,7 +24,7 @@ class PushTapRouter { PushActions.handleBackgroundResponse(response); return; } - final map = _payloadMap(response.payload); + final map = decodeNotificationPayload(response.payload); if (map == null) return; final target = resolvePushTarget(map); if (target != null) pendingTarget.value = target; @@ -50,13 +49,4 @@ class PushTapRouter { log('Reading the notification launch details failed: $e'); } } - - static Map? _payloadMap(String? payload) { - if (payload == null || payload.isEmpty) return null; - try { - return jsonDecode(payload) as Map; - } on Object { - return null; - } - } } diff --git a/lib/push/push_target.dart b/lib/push/push_target.dart index 11b24e0..7b765f5 100644 --- a/lib/push/push_target.dart +++ b/lib/push/push_target.dart @@ -1,3 +1,16 @@ +import 'dart:convert'; + +/// Decodes the JSON payload of a locally rendered notification; null when +/// absent or malformed. +Map? decodeNotificationPayload(String? payload) { + if (payload == null || payload.isEmpty) return null; + try { + return jsonDecode(payload) as Map; + } on Object { + return null; + } +} + /// Where a tapped notification leads. sealed class PushTarget { const PushTarget(); diff --git a/lib/routing/app_routes.dart b/lib/routing/app_routes.dart index 428eb55..03447c1 100644 --- a/lib/routing/app_routes.dart +++ b/lib/routing/app_routes.dart @@ -1,5 +1,6 @@ import 'dart:async'; +import 'package:collection/collection.dart'; import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; @@ -145,19 +146,17 @@ class AppRoutes { /// the selected element (or null if dismissed). The timetable view renders /// the chosen plan inline. Gated behind the `viewForeignTimetables` /// capability at the call site. - static Future openElementPicker( + static Future openElementPicker(BuildContext context) => + _pushForResult(context, const ElementPickerPage()); + + // pushScreen casts its internal MaterialPageRoute to `Route`, which blows + // up for a concrete (record) T — so push untyped (T = dynamic, like every + // other caller) and cast the popped result ourselves. + static Future _pushForResult( BuildContext context, - ) async { - // pushScreen casts its internal MaterialPageRoute to `Route`, which - // blows up for a concrete (record) T — so push untyped (T = dynamic, like - // every other caller) and cast the popped result ourselves. - final result = await pushScreen( - context, - withNavBar: false, - screen: const ElementPickerPage(), - ); - return result as TimetableElementRef?; - } + Widget screen, + ) async => + (await pushScreen(context, withNavBar: false, screen: screen)) as T?; /// Opens the user/group picker for sharing a file and resolves to the chosen /// recipient (or null if dismissed). Which kinds are offered is gated by the @@ -166,19 +165,10 @@ class AppRoutes { BuildContext context, { required bool allowUsers, required bool allowGroups, - }) async { - // Push untyped (T = dynamic) and cast the popped result ourselves — - // pushScreen can't carry a concrete non-Widget T (see openElementPicker). - final result = await pushScreen( - context, - withNavBar: false, - screen: ShareePickerPage( - allowUsers: allowUsers, - allowGroups: allowGroups, - ), - ); - return result as ShareeRef?; - } + }) => _pushForResult( + context, + ShareePickerPage(allowUsers: allowUsers, allowGroups: allowGroups), + ); static void openMarianumMessage( BuildContext context, @@ -452,13 +442,7 @@ class AppRoutes { static GetRoomResponseObject? _findRoomByToken( GetRoomResponse? rooms, String token, - ) { - if (rooms == null) return null; - for (final room in rooms.data) { - if (room.token == token) return room; - } - return null; - } + ) => rooms?.data.firstWhereOrNull((room) => room.token == token); /// Pushes a module from the "Mehr" tab list. Modules already in the bottom /// bar are switched to via [goToTab] instead. diff --git a/lib/share_intent/pending_share.dart b/lib/share_intent/pending_share.dart index 6c875e2..358271b 100644 --- a/lib/share_intent/pending_share.dart +++ b/lib/share_intent/pending_share.dart @@ -19,7 +19,6 @@ class PendingShare { bool get hasFiles => filePaths.isNotEmpty; bool get hasText => text != null && text!.isNotEmpty; - bool get isEmpty => !hasFiles && !hasText; /// True when [other] carries the same payload. The iOS Share Extension /// fires two `open(url)` requests per share (see ShareViewController), so diff --git a/lib/state/app/infrastructure/loadable_state/view/loadable_state_consumer.dart b/lib/state/app/infrastructure/loadable_state/view/loadable_state_consumer.dart index d18a7ae..8c5ea66 100644 --- a/lib/state/app/infrastructure/loadable_state/view/loadable_state_consumer.dart +++ b/lib/state/app/infrastructure/loadable_state/view/loadable_state_consumer.dart @@ -40,7 +40,7 @@ class LoadableStateConsumer< super.key, }); - static Duration animationDuration = const Duration(milliseconds: 200); + static const Duration animationDuration = Duration(milliseconds: 200); @override Widget build(BuildContext context) { diff --git a/lib/state/app/infrastructure/loadable_state/view/loadable_state_error_bar.dart b/lib/state/app/infrastructure/loadable_state/view/loadable_state_error_bar.dart index df6eaef..333b39a 100644 --- a/lib/state/app/infrastructure/loadable_state/view/loadable_state_error_bar.dart +++ b/lib/state/app/infrastructure/loadable_state/view/loadable_state_error_bar.dart @@ -5,6 +5,7 @@ import 'package:flutter_bloc/flutter_bloc.dart'; import '../../../../../widget/info_dialog.dart'; import '../bloc/loadable_state_bloc.dart'; +import 'loadable_state_consumer.dart'; class LoadableStateErrorBar extends StatelessWidget { final bool visible; @@ -21,8 +22,6 @@ class LoadableStateErrorBar extends StatelessWidget { super.key, }); - final Duration animationDuration = const Duration(milliseconds: 200); - @override Widget build(BuildContext context) { final bloc = context.watch(); @@ -31,9 +30,9 @@ class LoadableStateErrorBar extends StatelessWidget { final shouldShow = visible || isOfflineWithCache; return AnimatedSize( - duration: animationDuration, + duration: LoadableStateConsumer.animationDuration, child: AnimatedSwitcher( - duration: animationDuration, + duration: LoadableStateConsumer.animationDuration, transitionBuilder: (Widget child, Animation animation) => SlideTransition( position: Tween( @@ -46,35 +45,27 @@ class LoadableStateErrorBar extends StatelessWidget { key: Key(shouldShow.hashCode.toString()), visible: shouldShow, replacement: const SizedBox(width: double.infinity), - child: Builder( - builder: (context) { - var bloc = context.watch(); - return InkWell( - onTap: () { - if (!bloc.isConnected()) return; - final body = [ - if (message != null && message!.isNotEmpty) message!, - if (technicalDetails != null && - technicalDetails!.isNotEmpty) - technicalDetails!, - ].join('\n\n'); - if (body.isEmpty) return; - InfoDialog.show( - context, - body, - copyable: true, - title: 'Fehlerdetails', - ); - }, - child: Container( - height: 20, - decoration: BoxDecoration( - color: bloc.connectionColor(context), - ), - child: LoadableStateErrorBarText(lastUpdated: lastUpdated), - ), + child: InkWell( + onTap: () { + if (!bloc.isConnected()) return; + final body = [ + if (message != null && message!.isNotEmpty) message!, + if (technicalDetails != null && technicalDetails!.isNotEmpty) + technicalDetails!, + ].join('\n\n'); + if (body.isEmpty) return; + InfoDialog.show( + context, + body, + copyable: true, + title: 'Fehlerdetails', ); }, + child: Container( + height: 20, + decoration: BoxDecoration(color: bloc.connectionColor(context)), + child: LoadableStateErrorBarText(lastUpdated: lastUpdated), + ), ), ), ), @@ -106,8 +97,9 @@ class _LoadableStateErrorBarTextState extends State { @override Widget build(BuildContext context) { - var bloc = context.watch(); + final bloc = context.watch(); final foreground = bloc.connectionForegroundColor(context); + final text = bloc.connectionText(lastUpdated: widget.lastUpdated); // liveRegion, damit das Auftauchen des Offline-/Fehlerbanners angesagt wird; // Row-Semantik ausgeschlossen (Icon + Text) und Text über das explizite @@ -115,17 +107,14 @@ class _LoadableStateErrorBarTextState extends State { return Semantics( liveRegion: true, container: true, - label: bloc.connectionText(lastUpdated: widget.lastUpdated), + label: text, child: ExcludeSemantics( child: Row( mainAxisAlignment: MainAxisAlignment.center, children: [ Icon(bloc.connectionIcon(), size: 14, color: foreground), const SizedBox(width: 10), - Text( - bloc.connectionText(lastUpdated: widget.lastUpdated), - style: TextStyle(fontSize: 12, color: foreground), - ), + Text(text, style: TextStyle(fontSize: 12, color: foreground)), ], ), ), diff --git a/lib/state/app/infrastructure/utility_widgets/loadable_hydrated_bloc/loadable_hydrated_bloc.dart b/lib/state/app/infrastructure/utility_widgets/loadable_hydrated_bloc/loadable_hydrated_bloc.dart index 71aaec0..b7acc47 100644 --- a/lib/state/app/infrastructure/utility_widgets/loadable_hydrated_bloc/loadable_hydrated_bloc.dart +++ b/lib/state/app/infrastructure/utility_widgets/loadable_hydrated_bloc/loadable_hydrated_bloc.dart @@ -253,7 +253,6 @@ abstract class LoadableHydratedBloc< /// `==`, so use fields that compare cheaply, e.g. by identity). Object? persistenceKey(TState data) => data; - Future gatherData(); TRepository repository(); diff --git a/lib/state/app/modules/app_modules.dart b/lib/state/app/modules/app_modules.dart index 97e94fa..68ffd7f 100644 --- a/lib/state/app/modules/app_modules.dart +++ b/lib/state/app/modules/app_modules.dart @@ -177,9 +177,7 @@ class AppModule { for (final missing in Modules.values) { if (!seen.add(missing)) continue; var insertAt = 0; - for (final predecessor in Modules.values.takeWhile( - (m) => m != missing, - )) { + for (final predecessor in Modules.values.takeWhile((m) => m != missing)) { final pos = order.indexOf(predecessor); if (pos >= insertAt) insertAt = pos + 1; } @@ -231,33 +229,26 @@ class AppModule { static const int minBottomBarSlots = 3; static const int maxBottomBarSlots = 5; - static int resolveBottomBarSlotCount(BuildContext context) { + static int _slotCount(BuildContext context, int available) { final settings = context.read().val().modulesSettings; - final available = modules(context).length; - - int desired; - if (settings.autoFillBottomBar) { - // 4 content tabs so the bar shows 5 entries including "Mehr" — enough - // for the Ticker module without pushing "Dateien" into the overflow. - desired = 4; - } else { - desired = settings.fixedBottomBarSlots; - } - - desired = desired.clamp(minBottomBarSlots, maxBottomBarSlots); - return desired.clamp(0, available); + // 4 content tabs so the bar shows 5 entries including "Mehr" — enough + // for the Ticker module without pushing "Dateien" into the overflow. + final desired = settings.autoFillBottomBar + ? 4 + : settings.fixedBottomBarSlots; + return desired + .clamp(minBottomBarSlots, maxBottomBarSlots) + .clamp(0, available); } static List getBottomBarModules(BuildContext context) { final all = modules(context).values.toList(); - final slots = resolveBottomBarSlotCount(context); - return all.take(slots).toList(); + return all.take(_slotCount(context, all.length)).toList(); } static List getOverhangModules(BuildContext context) { final all = modules(context).values.toList(); - final slots = resolveBottomBarSlotCount(context); - return all.skip(slots).toList(); + return all.skip(_slotCount(context, all.length)).toList(); } Widget toListTile( @@ -290,10 +281,7 @@ class AppModule { : const Icon(Icons.arrow_right), ); - PersistentTabConfig toBottomTab( - BuildContext context, { - Widget Function(IconData icon)? iconBuilder, - }) => PersistentTabConfig( + PersistentTabConfig toBottomTab(BuildContext context) => PersistentTabConfig( screen: Breaker(breaker: breakerArea, child: create()), item: ItemConfig( activeForegroundColor: Theme.of(context).primaryColor, diff --git a/lib/state/app/modules/breaker/data_provider/breaker_data_provider.dart b/lib/state/app/modules/breaker/data_provider/breaker_data_provider.dart index fd22cf3..be5a131 100644 --- a/lib/state/app/modules/breaker/data_provider/breaker_data_provider.dart +++ b/lib/state/app/modules/breaker/data_provider/breaker_data_provider.dart @@ -12,9 +12,9 @@ class BreakerDataProvider { bool renew = true, void Function(Object)? onError, }) => resolveFromCache( - (onUpdate, onError) => - GetBreakersCache(renew: renew, onUpdate: onUpdate, onError: onError), - onError: onError, - operationName: 'getBreakers', - ); + (onUpdate, onError) => + GetBreakersCache(renew: renew, onUpdate: onUpdate, onError: onError), + onError: onError, + operationName: 'getBreakers', + ); } diff --git a/lib/state/app/modules/chat/bloc/chat_bloc.dart b/lib/state/app/modules/chat/bloc/chat_bloc.dart index ebba68a..59f5d23 100644 --- a/lib/state/app/modules/chat/bloc/chat_bloc.dart +++ b/lib/state/app/modules/chat/bloc/chat_bloc.dart @@ -1,6 +1,5 @@ import 'dart:async'; import 'dart:developer'; -import 'dart:math' as math; import 'package:flutter/widgets.dart'; @@ -10,6 +9,7 @@ import '../../../../../api/marianumcloud/talk/chat/long_poll_chat.dart'; import '../../../../../api/marianumcloud/talk/room/get_room_response.dart'; import '../../../../../api/marianumcloud/talk/set_read_marker/set_read_marker.dart'; import '../../../../../api/marianumcloud/talk/set_read_marker/set_read_marker_params.dart'; +import '../../../../../utils/exponential_backoff.dart'; import '../../../infrastructure/utility_widgets/loadable_hydrated_bloc/loadable_hydrated_bloc.dart'; import '../../../infrastructure/utility_widgets/loadable_hydrated_bloc/loadable_hydrated_bloc_event.dart'; import '../../chat_list/bloc/chat_list_bloc.dart'; @@ -26,7 +26,6 @@ class ChatBloc // A loop parked in its request survives stop+start for the same token // (pause/resume); the generation retires it so only one loop polls. int _pollGeneration = 0; - int _backoffMs = 0; int _lastKnownMessageId = 0; bool _appResumed = true; @@ -68,8 +67,7 @@ class ChatBloc @override Map? toStorage(ChatState state) { final response = state.chatResponse; - if (response == null || - response.data.length <= _kMaxPersistedMessages) { + if (response == null || response.data.length <= _kMaxPersistedMessages) { return state.toJson(); } // Keep only the newest N; trimming drops older messages, so there is @@ -129,9 +127,6 @@ class ChatBloc add(Emit((s) => s.copyWith(referenceMessageId: messageId))); } - /// No-op when the bloc has already moved on to a different token: when - /// popping a stacked chat (B over A), A's didPopNext runs setToken(A) - /// before B's dispose fires. /// The chat view may still be popping when the sign-out resets this bloc, /// so leaveChat would find no token and the long-poll would keep running. @override @@ -141,6 +136,9 @@ class ChatBloc return super.reset(); } + /// No-op when the bloc has already moved on to a different token: when + /// popping a stacked chat (B over A), A's didPopNext runs setToken(A) + /// before B's dispose fires. void leaveChat(String fromToken) { if ((innerState?.currentToken ?? '') != fromToken) return; _chatViewActive = false; @@ -272,7 +270,6 @@ class ChatBloc if (_pollingToken == token) return; _stopLongPoll(); _pollingToken = token; - _backoffMs = 0; _lastKnownMessageId = _maxMessageId(innerState?.chatResponse); unawaited(_pollLoop(token, ++_pollGeneration)); } @@ -280,11 +277,11 @@ class ChatBloc void _stopLongPoll() { _pollingToken = null; _pollGeneration++; - _backoffMs = 0; } Future _pollLoop(String token, int generation) async { bool active() => generation == _pollGeneration && !isClosed; + var failures = 0; while (active()) { try { final response = await LongPollChat( @@ -293,7 +290,7 @@ class ChatBloc ).run(); if (!active()) return; - _backoffMs = 0; + failures = 0; if (response == null) continue; @@ -318,8 +315,13 @@ class ChatBloc } on Object catch (e) { if (!active()) return; log('LongPoll error for $token: $e'); - _backoffMs = _backoffMs == 0 ? 2000 : math.min(_backoffMs * 2, 30000); - await Future.delayed(Duration(milliseconds: _backoffMs)); + await Future.delayed( + exponentialBackoff( + ++failures, + base: const Duration(seconds: 2), + max: const Duration(seconds: 30), + ), + ); } } } @@ -339,14 +341,7 @@ class ChatBloc ); return; } - final byId = {}; - for (final m in current.data) { - byId[m.id] = m; - } - for (final m in incoming.data) { - byId[m.id] = m; - } - final merged = GetChatResponse(byId.values.toSet()) + final merged = _mergeById(current, incoming, incomingWins: true) ..headers = incoming.headers; add(DataGathered((s) => s.copyWith(chatResponse: merged))); } @@ -357,14 +352,7 @@ class ChatBloc void _applyOlderResponse(GetChatResponse older) { final current = innerState?.chatResponse; if (current == null) return; - final byId = {}; - for (final m in current.data) { - byId[m.id] = m; - } - for (final m in older.data) { - byId.putIfAbsent(m.id, () => m); - } - final merged = GetChatResponse(byId.values.toSet()) + final merged = _mergeById(current, older, incomingWins: false) ..headers = current.headers; add( DataGathered( @@ -377,6 +365,22 @@ class ChatBloc ); } + GetChatResponse _mergeById( + GetChatResponse current, + GetChatResponse incoming, { + required bool incomingWins, + }) { + final byId = {for (final m in current.data) m.id: m}; + for (final m in incoming.data) { + if (incomingWins) { + byId[m.id] = m; + } else { + byId.putIfAbsent(m.id, () => m); + } + } + return GetChatResponse(byId.values.toSet()); + } + int _maxMessageId(GetChatResponse? response) { if (response == null) return 0; var max = 0; @@ -386,8 +390,7 @@ class ChatBloc return max; } - int _minMessageId(GetChatResponse? response) { - if (response == null) return 0; + int _minMessageId(GetChatResponse response) { var min = 0; for (final m in response.data) { if (m.id <= 0) continue; // skip dummies diff --git a/lib/state/app/modules/files/bloc/files_bloc.dart b/lib/state/app/modules/files/bloc/files_bloc.dart index caa9162..ca8e4c9 100644 --- a/lib/state/app/modules/files/bloc/files_bloc.dart +++ b/lib/state/app/modules/files/bloc/files_bloc.dart @@ -28,18 +28,16 @@ class FilesBloc @override Map? toStorage(FilesState state) => null; + List get _currentPath => innerState?.currentPath ?? initialPath; + @override - Future gatherData() async { - final path = innerState?.currentPath ?? initialPath; - await _query(path); - } + Future gatherData() => _query(_currentPath); Future refresh() async { add(RefetchStarted()); - final path = innerState?.currentPath ?? initialPath; // Explicit user action — bypass the cache TTL so the root listing also // refetches even though it is otherwise cached for a day. - await _query(path, renew: true); + await _query(_currentPath, renew: true); } /// Pull-to-refresh and error-screen retry route through here. Unlike the @@ -57,13 +55,15 @@ class FilesBloc } Future createFolder(String name) async { - final path = innerState?.currentPath ?? initialPath; - await repo.data.createFolder('${path.join('/')}/$name'); + await repo.data.createFolder('${_currentPath.join('/')}/$name'); await refresh(); } Future _query(List path, {bool renew = false}) async { - final pathString = path.isEmpty ? '/' : path.join('/'); + // The listing contains the folder itself; only its children are shown. + void dropSelf(ListFilesResponse listing) => listing.files.removeWhere( + (file) => file.name.isEmpty || file.name == path.lastOrNull, + ); // Drop late results when [setPath] has navigated elsewhere or when the // bloc has been disposed (e.g. share-flow picker closed mid-fetch). Both @@ -80,16 +80,14 @@ class FilesBloc ListFilesResponse? listing; try { listing = await repo.data.listFiles( - pathString, + path.isEmpty ? '/' : path.join('/'), renew: renew, onCacheData: (cached) { if (isStale()) return; // Cached payload arrives before the network call settles. Surface it // immediately via Emit so the listing is visible while isLoading // stays true and the top loading bar keeps spinning. - cached.files.removeWhere( - (file) => file.name.isEmpty || file.name == path.lastOrNull, - ); + dropSelf(cached); add(Emit((s) => s.copyWith(listing: cached))); }, onRetry: (next, max) { @@ -105,9 +103,7 @@ class FilesBloc if (isStale()) return; if (listing != null) { - listing.files.removeWhere( - (file) => file.name.isEmpty || file.name == path.lastOrNull, - ); + dropSelf(listing); add(DataGathered((s) => s.copyWith(listing: listing))); } if (capturedError != null) addLoadingError(capturedError!); diff --git a/lib/state/app/modules/files/repository/files_repository.dart b/lib/state/app/modules/files/repository/files_repository.dart index 35f316c..6058279 100644 --- a/lib/state/app/modules/files/repository/files_repository.dart +++ b/lib/state/app/modules/files/repository/files_repository.dart @@ -3,10 +3,5 @@ import '../bloc/files_state.dart'; import '../data_provider/files_data_provider.dart'; class FilesRepository extends Repository { - final FilesDataProvider _provider; - - FilesRepository([FilesDataProvider? provider]) - : _provider = provider ?? FilesDataProvider(); - - FilesDataProvider get data => _provider; + final FilesDataProvider data = FilesDataProvider(); } diff --git a/lib/state/app/modules/foreign_timetable/bloc/foreign_timetable_bloc.dart b/lib/state/app/modules/foreign_timetable/bloc/foreign_timetable_bloc.dart index 98f3bd3..10c8df9 100644 --- a/lib/state/app/modules/foreign_timetable/bloc/foreign_timetable_bloc.dart +++ b/lib/state/app/modules/foreign_timetable/bloc/foreign_timetable_bloc.dart @@ -1,16 +1,14 @@ -import 'dart:developer'; - import '../../../../../api/marianumconnect/queries/timetable_get_element_week/timetable_element_type.dart'; import '../../../../../api/marianumconnect/queries/timetable_get_week/timetable_get_week_response.dart'; -import '../../../../../extensions/date_time.dart'; import '../../../infrastructure/loadable_state/loadable_state.dart'; import '../../../infrastructure/utility_widgets/loadable_hydrated_bloc/loadable_hydrated_bloc.dart'; -import '../../../infrastructure/utility_widgets/loadable_hydrated_bloc/loadable_hydrated_bloc_event.dart'; import '../../timetable/bloc/timetable_event.dart'; import '../../timetable/bloc/timetable_state.dart'; +import '../../timetable/bloc/timetable_week_loader.dart'; +import '../../timetable/data_provider/timetable_data_provider.dart'; import '../repository/foreign_timetable_repository.dart'; -/// Drives a foreign element's timetable. Mirrors `TimetableBloc`'s week-loading +/// Drives a foreign element's timetable. Shares `TimetableBloc`'s week loading /// and navigation but loads weeks from the element endpoint, carries no custom /// events, and does not persist (page-scoped, recreated per element). Reuses /// [TimetableState] verbatim so the render pipeline is unchanged; `customEvents` @@ -21,34 +19,25 @@ class ForeignTimetableBloc TimetableEvent, TimetableState, ForeignTimetableRepository - > { - + > + with TimetableWeekLoader { final TimetableElementType type; // Named `elementId` rather than `id` to avoid shadowing HydratedMixin's // `String get id` (the storage key), which a plain `int id` would illegally // override. final int elementId; - final String title; - DateTime _lastWeekRequestStart = DateTime.fromMillisecondsSinceEpoch(0); - - ForeignTimetableBloc({ - required this.type, - required this.elementId, - required this.title, - }); + ForeignTimetableBloc({required this.type, required this.elementId}); @override ForeignTimetableRepository repository() => ForeignTimetableRepository(); @override - TimetableState fromNothing() { - final reference = DateTime.now().addDays(2); - return TimetableState( - startDate: reference.mondayOfWeek, - endDate: _endOfWeek(reference), - ); - } + TimetableDataProvider get referenceData => repo.data.referenceData; + + @override + Future fetchWeek(DateTime start, DateTime end) => + repo.data.getElementWeek(type, elementId, start, end); // Persistence disabled: page-scoped and element-specific, nothing worth // restoring. toJson returns null so nothing is written; fromJson starts fresh. @@ -72,116 +61,5 @@ class ForeignTimetableBloc Map? toStorage(TimetableState state) => null; @override - Future gatherData() async { - final initial = innerState ?? fromNothing(); - - Object? firstError; - void recordError(Object e) { - firstError ??= e; - } - - await Future.wait([ - _loadCurrentWeek(initial.startDate, initial.endDate, onError: recordError), - _loadStaticReferenceData(onError: recordError), - ]); - - if (firstError != null) throw firstError!; - - add(DataGathered((s) => s)); - _prefetchAdjacentWeeks(initial.startDate, initial.endDate); - } - - void changeWeek(DateTime startDate, DateTime endDate) { - final current = innerState ?? fromNothing(); - if (current.startDate == startDate && current.endDate == endDate) return; - add(Emit((s) => s.copyWith(startDate: startDate, endDate: endDate))); - _loadCurrentWeek(startDate, endDate); - _prefetchAdjacentWeeks(startDate, endDate); - } - - void resetWeek() { - final reference = DateTime.now().addDays(2); - changeWeek(reference.mondayOfWeek, _endOfWeek(reference)); - } - - void refresh() => fetch(); - - Future _loadCurrentWeek( - DateTime startDate, - DateTime endDate, { - void Function(Object)? onError, - }) async { - final requestStart = DateTime.now(); - _lastWeekRequestStart = requestStart; - try { - final week = await repo.data.getElementWeek( - type, - elementId, - startDate, - endDate, - onError: onError, - ); - if (_lastWeekRequestStart.isAfter(requestStart)) return; - _writeWeekToCache(startDate, week); - } catch (e) { - log('getElementWeek error for $startDate–$endDate: $e'); - onError?.call(e); - } - } - - Future _loadStaticReferenceData({ - void Function(Object)? onError, - }) async { - try { - final (rooms, subjects, schoolHolidays, schoolyear) = await ( - repo.data.getRooms(onError: onError), - repo.data.getSubjects(onError: onError), - repo.data.getSchoolHolidays(onError: onError), - repo.data.getCurrentSchoolyear(onError: onError), - ).wait; - - add( - Emit( - (s) => s.withReferenceData( - rooms: rooms, - subjects: subjects, - schoolHolidays: schoolHolidays, - schoolyear: schoolyear, - ), - ), - ); - } catch (e) { - onError?.call(e); - } - - try { - final timegrid = await repo.data.getTimegrid(); - add(Emit((s) => s.withReferenceData(timegrid: timegrid))); - } catch (_) { - // Timegrid load failure falls back to a hardcoded schedule in the UI. - } - } - - void _prefetchAdjacentWeeks(DateTime start, DateTime end) { - _prefetchWeek(start.subtractDays(7), end.subtractDays(7)); - _prefetchWeek(start.addDays(7), end.addDays(7)); - } - - void _prefetchWeek(DateTime start, DateTime end) { - repo.data - .getElementWeek(type, elementId, start, end) - .then((week) => _writeWeekToCache(start, week)) - .catchError((_) {}); - } - - void _writeWeekToCache(DateTime weekStart, TimetableGetWeekResponse week) { - add(Emit((s) => s.withFetchedWeek(weekStart, week))); - } - - static DateTime _endOfWeek(DateTime reference) { - final friday = reference.addDays( - DateTime.daysPerWeek - reference.weekday - 2, - ); - return DateTime(friday.year, friday.month, friday.day); - } + Future gatherData() => gatherWeek(); } diff --git a/lib/state/app/modules/foreign_timetable/data_provider/foreign_timetable_data_provider.dart b/lib/state/app/modules/foreign_timetable/data_provider/foreign_timetable_data_provider.dart index 304065f..00cfa7c 100644 --- a/lib/state/app/modules/foreign_timetable/data_provider/foreign_timetable_data_provider.dart +++ b/lib/state/app/modules/foreign_timetable/data_provider/foreign_timetable_data_provider.dart @@ -1,64 +1,27 @@ import '../../../../../api/marianumconnect/queries/timetable_get_element_week/timetable_element_type.dart'; import '../../../../../api/marianumconnect/queries/timetable_get_element_week/timetable_get_element_week.dart'; -import '../../../../../api/marianumconnect/queries/timetable_get_holidays/timetable_get_holidays_response.dart'; -import '../../../../../api/marianumconnect/queries/timetable_get_rooms/timetable_get_rooms_response.dart'; -import '../../../../../api/marianumconnect/queries/timetable_get_schoolyear/timetable_get_schoolyear_response.dart'; -import '../../../../../api/marianumconnect/queries/timetable_get_subjects/timetable_get_subjects_response.dart'; -import '../../../../../api/marianumconnect/queries/timetable_get_timegrid/timetable_get_timegrid_response.dart'; import '../../../../../api/marianumconnect/queries/timetable_get_week/timetable_get_week_response.dart'; import '../../timetable/data_provider/timetable_data_provider.dart'; /// Data access for a foreign element's timetable. The week comes from the /// element-specific endpoint; all reference data (rooms/subjects/holidays/ -/// school year/timegrid) is school-wide, so it delegates to the existing -/// [TimetableDataProvider] (which caches it). Custom events are intentionally -/// absent — they are user-private. +/// school year/timegrid) is school-wide and comes from [referenceData]. +/// Custom events are intentionally absent — they are user-private. class ForeignTimetableDataProvider { - final TimetableDataProvider _base; + final TimetableDataProvider referenceData; - ForeignTimetableDataProvider([TimetableDataProvider? base]) - : _base = base ?? TimetableDataProvider(); + ForeignTimetableDataProvider([TimetableDataProvider? referenceData]) + : referenceData = referenceData ?? TimetableDataProvider(); Future getElementWeek( TimetableElementType type, int id, DateTime startDate, - DateTime endDate, { - void Function(Object)? onError, - }) async { - try { - return await TimetableGetElementWeek().run( - type: type, - id: id, - from: startDate, - until: endDate, - ); - } catch (e) { - onError?.call(e); - rethrow; - } - } - - Future getRooms({ - void Function(Object)? onError, - bool renew = false, - }) => _base.getRooms(onError: onError, renew: renew); - - Future getSubjects({ - void Function(Object)? onError, - bool renew = false, - }) => _base.getSubjects(onError: onError, renew: renew); - - Future getSchoolHolidays({ - void Function(Object)? onError, - bool renew = false, - }) => _base.getSchoolHolidays(onError: onError, renew: renew); - - Future getCurrentSchoolyear({ - void Function(Object)? onError, - bool renew = false, - }) => _base.getCurrentSchoolyear(onError: onError, renew: renew); - - Future getTimegrid({bool renew = false}) => - _base.getTimegrid(renew: renew); + DateTime endDate, + ) async => TimetableGetElementWeek().run( + type: type, + id: id, + from: startDate, + until: endDate, + ); } diff --git a/lib/state/app/modules/holidays/bloc/holidays_bloc.dart b/lib/state/app/modules/holidays/bloc/holidays_bloc.dart index 4e5d4fe..e7c229e 100644 --- a/lib/state/app/modules/holidays/bloc/holidays_bloc.dart +++ b/lib/state/app/modules/holidays/bloc/holidays_bloc.dart @@ -9,13 +9,13 @@ class HolidaysBloc extends LoadableHydratedBloc { HolidaysBloc() { - on((event, emit) { - add( + on( + (event, emit) => add( Emit( (state) => state.copyWith(showPastHolidays: event.shouldBeVisible), ), - ); - }); + ), + ); on( (event, emit) => @@ -25,15 +25,13 @@ class HolidaysBloc bool showPastHolidays() => innerState?.showPastHolidays ?? false; bool showDisclaimerOnEntry() => innerState?.showDisclaimer ?? false; - List? getHolidays() => - innerState?.holidays - .where( - (element) => - showPastHolidays() || - element.endDate.isAfter(DateTime.now()), - ) - .toList() ?? - []; + List getHolidays() { + final now = DateTime.now(); + return innerState?.holidays + .where((h) => showPastHolidays() || h.endDate.isAfter(now)) + .toList() ?? + []; + } @override HolidaysState fromNothing() => const HolidaysState( diff --git a/lib/state/app/modules/marianum_dates/bloc/marianum_dates_bloc.dart b/lib/state/app/modules/marianum_dates/bloc/marianum_dates_bloc.dart index a0ebeed..11ecde8 100644 --- a/lib/state/app/modules/marianum_dates/bloc/marianum_dates_bloc.dart +++ b/lib/state/app/modules/marianum_dates/bloc/marianum_dates_bloc.dart @@ -12,20 +12,22 @@ class MarianumDatesBloc MarianumDatesRepository > { MarianumDatesBloc() { - on((event, emit) { - add( + on( + (event, emit) => add( Emit((state) => state.copyWith(showPastEvents: event.shouldBeVisible)), - ); - }); + ), + ); } bool showPastEvents() => innerState?.showPastEvents ?? false; - List? getEvents() => - innerState?.events - .where((e) => showPastEvents() || e.end.isAfter(DateTime.now())) - .toList() ?? - []; + List getEvents() { + final now = DateTime.now(); + return innerState?.events + .where((e) => showPastEvents() || e.end.isAfter(now)) + .toList() ?? + []; + } @override MarianumDatesState fromNothing() => diff --git a/lib/state/app/modules/marianum_message/bloc/marianum_message_event.dart b/lib/state/app/modules/marianum_message/bloc/marianum_message_event.dart index 6ad6e1c..1c8679c 100644 --- a/lib/state/app/modules/marianum_message/bloc/marianum_message_event.dart +++ b/lib/state/app/modules/marianum_message/bloc/marianum_message_event.dart @@ -3,5 +3,3 @@ import 'marianum_message_state.dart'; sealed class MarianumMessageEvent extends LoadableHydratedBlocEvent {} - -class MessageEvent extends MarianumMessageEvent {} diff --git a/lib/state/app/modules/marianum_message/bloc/marianum_message_state.dart b/lib/state/app/modules/marianum_message/bloc/marianum_message_state.dart index 4a0742c..605b5a1 100644 --- a/lib/state/app/modules/marianum_message/bloc/marianum_message_state.dart +++ b/lib/state/app/modules/marianum_message/bloc/marianum_message_state.dart @@ -37,5 +37,3 @@ abstract class MarianumMessage with _$MarianumMessage { factory MarianumMessage.fromJson(Map json) => _$MarianumMessageFromJson(json); } - -enum GradeAveragesGradingSystem { highSchool, middleSchool } diff --git a/lib/state/app/modules/marianum_message/data_provider/marianum_message_get_messages.dart b/lib/state/app/modules/marianum_message/data_provider/marianum_message_get_messages.dart index a0f7e69..74d47da 100644 --- a/lib/state/app/modules/marianum_message/data_provider/marianum_message_get_messages.dart +++ b/lib/state/app/modules/marianum_message/data_provider/marianum_message_get_messages.dart @@ -12,7 +12,8 @@ import '../bloc/marianum_message_state.dart'; class MarianumMessageGetMessages { final Dio _dio; - MarianumMessageGetMessages({Dio? dio}) : _dio = dio ?? MarianumConnectApi.dio(); + MarianumMessageGetMessages({Dio? dio}) + : _dio = dio ?? MarianumConnectApi.dio(); Future run() async { try { diff --git a/lib/state/app/modules/ticker/bloc/ticker_event.dart b/lib/state/app/modules/ticker/bloc/ticker_event.dart index 8a0e5bb..8d5ca26 100644 --- a/lib/state/app/modules/ticker/bloc/ticker_event.dart +++ b/lib/state/app/modules/ticker/bloc/ticker_event.dart @@ -2,5 +2,3 @@ import '../../../infrastructure/utility_widgets/loadable_hydrated_bloc/loadable_ import 'ticker_state.dart'; sealed class TickerEvent extends LoadableHydratedBlocEvent {} - -class TickerLoadEvent extends TickerEvent {} diff --git a/lib/state/app/modules/ticker/bloc/ticker_page_event.dart b/lib/state/app/modules/ticker/bloc/ticker_page_event.dart index 6c0ae04..57bc448 100644 --- a/lib/state/app/modules/ticker/bloc/ticker_page_event.dart +++ b/lib/state/app/modules/ticker/bloc/ticker_page_event.dart @@ -1,6 +1,5 @@ import '../../../infrastructure/utility_widgets/loadable_hydrated_bloc/loadable_hydrated_bloc_event.dart'; import 'ticker_page_state.dart'; -sealed class TickerPageEvent extends LoadableHydratedBlocEvent {} - -class TickerPageLoadEvent extends TickerPageEvent {} +sealed class TickerPageEvent + extends LoadableHydratedBlocEvent {} diff --git a/lib/state/app/modules/timetable/bloc/timetable_bloc.dart b/lib/state/app/modules/timetable/bloc/timetable_bloc.dart index 5f081d5..c16da18 100644 --- a/lib/state/app/modules/timetable/bloc/timetable_bloc.dart +++ b/lib/state/app/modules/timetable/bloc/timetable_bloc.dart @@ -1,14 +1,13 @@ -import 'dart:developer'; - import '../../../../../api/marianumconnect/queries/timetable_custom_events/custom_events_migration.dart'; import '../../../../../api/marianumconnect/queries/timetable_get_week/timetable_get_week_response.dart'; import '../../../../../api/mhsl/custom_timetable_event/custom_timetable_event.dart'; -import '../../../../../extensions/date_time.dart'; import '../../../infrastructure/utility_widgets/loadable_hydrated_bloc/loadable_hydrated_bloc.dart'; import '../../../infrastructure/utility_widgets/loadable_hydrated_bloc/loadable_hydrated_bloc_event.dart'; +import '../data_provider/timetable_data_provider.dart'; import '../repository/timetable_repository.dart'; import 'timetable_event.dart'; import 'timetable_state.dart'; +import 'timetable_week_loader.dart'; class TimetableBloc extends @@ -16,11 +15,10 @@ class TimetableBloc TimetableEvent, TimetableState, TimetableRepository - > { - DateTime _lastWeekRequestStart = DateTime.fromMillisecondsSinceEpoch(0); - - /// Set by [retry] to force the next [gatherData] to bypass cache freshness - /// checks and actually hit the network. Cleared at the top of [gatherData]. + > + with TimetableWeekLoader { + /// Set by [retry] to force the next [gatherData] to bypass the custom-event + /// cache and actually hit the network. Cleared at the top of [gatherData]. bool _forceRenew = false; @override @@ -33,24 +31,21 @@ class TimetableBloc TimetableRepository repository() => TimetableRepository(); @override - TimetableState fromNothing() { - final reference = DateTime.now().addDays(2); - return TimetableState( - startDate: reference.mondayOfWeek, - endDate: _endOfWeek(reference), - ); - } + TimetableDataProvider get referenceData => repo.data; + + @override + Future fetchWeek(DateTime start, DateTime end) => + repo.data.getWeek(start, end); /// Persisted state may carry a stale `startDate`/`endDate` from the user's /// last view. Reset on every cold start so the calendar always mounts on /// the current week, not on whatever week the user closed the app on. @override TimetableState fromStorage(Map json) { - final stored = TimetableState.fromJson(json); - final reference = DateTime.now().addDays(2); - return stored.copyWith( - startDate: reference.mondayOfWeek, - endDate: _endOfWeek(reference), + final (start, end) = currentTimetableWeek(); + return TimetableState.fromJson(json).copyWith( + startDate: start, + endDate: end, accessibleStartDate: null, accessibleEndDate: null, ); @@ -60,50 +55,14 @@ class TimetableBloc Map? toStorage(TimetableState state) => state.toJson(); @override - Future gatherData() async { - final initial = innerState ?? fromNothing(); + Future gatherData() { final renew = _forceRenew; _forceRenew = false; - - Object? firstError; - void recordError(Object e) { - firstError ??= e; - } - - await Future.wait([ - _loadCurrentWeek( - initial.startDate, - initial.endDate, - onError: recordError, - renew: renew, - ), - _loadStaticReferenceData(onError: recordError, renew: renew), - _loadCustomEvents(onError: recordError, renew: renew), - ]); - - if (firstError != null) throw firstError!; - - add(DataGathered((s) => s)); - _prefetchAdjacentWeeks(initial.startDate, initial.endDate); + return gatherWeek( + also: (onError) => _loadCustomEvents(onError: onError, renew: renew), + ); } - void changeWeek(DateTime startDate, DateTime endDate) { - final current = innerState ?? fromNothing(); - if (current.startDate == startDate && current.endDate == endDate) return; - add(Emit((s) => s.copyWith(startDate: startDate, endDate: endDate))); - runInSession(() { - _loadCurrentWeek(startDate, endDate); - _prefetchAdjacentWeeks(startDate, endDate); - }); - } - - void resetWeek() { - final reference = DateTime.now().addDays(2); - changeWeek(reference.mondayOfWeek, _endOfWeek(reference)); - } - - void refresh() => fetch(); - Future addCustomEvent(CustomTimetableEvent event) async { await repo.data.addCustomEvent(event); await _refreshCustomEvents(); @@ -132,67 +91,10 @@ class TimetableBloc } Future _refreshSubjects() async { - final subjects = await repo.data.getSubjects(renew: true); + final subjects = await repo.data.getSubjects(); add(DataGathered((s) => s.withReferenceData(subjects: subjects))); } - Future _loadCurrentWeek( - DateTime startDate, - DateTime endDate, { - void Function(Object)? onError, - bool renew = false, - }) async { - final requestStart = DateTime.now(); - _lastWeekRequestStart = requestStart; - try { - final week = await repo.data.getWeek( - startDate, - endDate, - onError: onError, - renew: renew, - ); - if (_lastWeekRequestStart.isAfter(requestStart)) return; - _writeWeekToCache(startDate, week); - } catch (e) { - log('getWeek error for $startDate–$endDate: $e'); - onError?.call(e); - } - } - - Future _loadStaticReferenceData({ - void Function(Object)? onError, - bool renew = false, - }) async { - try { - final (rooms, subjects, schoolHolidays, schoolyear) = await ( - repo.data.getRooms(onError: onError, renew: renew), - repo.data.getSubjects(onError: onError, renew: renew), - repo.data.getSchoolHolidays(onError: onError, renew: renew), - repo.data.getCurrentSchoolyear(onError: onError, renew: renew), - ).wait; - - add( - Emit( - (s) => s.withReferenceData( - rooms: rooms, - subjects: subjects, - schoolHolidays: schoolHolidays, - schoolyear: schoolyear, - ), - ), - ); - } catch (e) { - onError?.call(e); - } - - try { - final timegrid = await repo.data.getTimegrid(renew: renew); - add(Emit((s) => s.withReferenceData(timegrid: timegrid))); - } catch (_) { - // Timegrid load failure falls back to a hardcoded schedule in the UI layer. - } - } - Future _loadCustomEvents({ void Function(Object)? onError, bool renew = false, @@ -213,27 +115,4 @@ class TimetableBloc final events = await repo.data.getCustomEvents(renew: true); add(DataGathered((s) => s.withReferenceData(customEvents: events))); } - - void _prefetchAdjacentWeeks(DateTime start, DateTime end) { - _prefetchWeek(start.subtractDays(7), end.subtractDays(7)); - _prefetchWeek(start.addDays(7), end.addDays(7)); - } - - void _prefetchWeek(DateTime start, DateTime end) { - repo.data - .getWeek(start, end) - .then((week) => _writeWeekToCache(start, week)) - .catchError((_) {}); - } - - void _writeWeekToCache(DateTime weekStart, TimetableGetWeekResponse week) { - add(Emit((s) => s.withFetchedWeek(weekStart, week))); - } - - static DateTime _endOfWeek(DateTime reference) { - final friday = reference.addDays( - DateTime.daysPerWeek - reference.weekday - 2, - ); - return DateTime(friday.year, friday.month, friday.day); - } } diff --git a/lib/state/app/modules/timetable/bloc/timetable_state.dart b/lib/state/app/modules/timetable/bloc/timetable_state.dart index a6f6641..c4e13dc 100644 --- a/lib/state/app/modules/timetable/bloc/timetable_state.dart +++ b/lib/state/app/modules/timetable/bloc/timetable_state.dart @@ -98,9 +98,9 @@ abstract class TimetableState with _$TimetableState { ); } - bool get hasReferenceData => - rooms != null && - subjects != null && - schoolHolidays != null && - customEvents != null; + /// Everything a foreign plan needs to render; it never has custom events. + bool get hasSchoolReferenceData => + rooms != null && subjects != null && schoolHolidays != null; + + bool get hasReferenceData => hasSchoolReferenceData && customEvents != null; } diff --git a/lib/state/app/modules/timetable/bloc/timetable_week_loader.dart b/lib/state/app/modules/timetable/bloc/timetable_week_loader.dart new file mode 100644 index 0000000..c067f87 --- /dev/null +++ b/lib/state/app/modules/timetable/bloc/timetable_week_loader.dart @@ -0,0 +1,138 @@ +import 'dart:developer'; + +import '../../../../../api/marianumconnect/queries/timetable_get_week/timetable_get_week_response.dart'; +import '../../../../../extensions/date_time.dart'; +import '../../../infrastructure/repository/repository.dart'; +import '../../../infrastructure/utility_widgets/loadable_hydrated_bloc/loadable_hydrated_bloc.dart'; +import '../../../infrastructure/utility_widgets/loadable_hydrated_bloc/loadable_hydrated_bloc_event.dart'; +import '../data_provider/timetable_data_provider.dart'; +import 'timetable_event.dart'; +import 'timetable_state.dart'; + +/// The date whose week the timetable opens on — two days ahead, so Saturday +/// and Sunday already show the next week. +DateTime timetableAnchorDate() => DateTime.now().addDays(2); + +/// Monday 00:00 to Friday 00:00 of the week the timetable opens on. +(DateTime, DateTime) currentTimetableWeek() { + final reference = timetableAnchorDate(); + final friday = reference.addDays( + DateTime.daysPerWeek - reference.weekday - 2, + ); + return (reference.mondayOfWeek, friday.dateOnly); +} + +/// Week navigation, week loading/prefetching and the school-wide reference +/// data, shared by the own and the foreign timetable. +mixin TimetableWeekLoader> + on LoadableHydratedBloc { + DateTime _lastWeekRequestStart = DateTime.fromMillisecondsSinceEpoch(0); + + Future fetchWeek(DateTime start, DateTime end); + + TimetableDataProvider get referenceData; + + @override + TimetableState fromNothing() { + final (start, end) = currentTimetableWeek(); + return TimetableState(startDate: start, endDate: end); + } + + /// Loads the viewed week and the reference data (plus [also]), fails with + /// the first error of any of them and prefetches the neighbouring weeks. + Future gatherWeek({ + Future Function(void Function(Object) onError)? also, + }) async { + final initial = innerState ?? fromNothing(); + + Object? firstError; + void recordError(Object e) => firstError ??= e; + + await Future.wait([ + _loadWeek(initial.startDate, initial.endDate, onError: recordError), + _loadReferenceData(onError: recordError), + if (also != null) also(recordError), + ]); + + if (firstError != null) throw firstError!; + + add(DataGathered((s) => s)); + _prefetchAdjacentWeeks(initial.startDate, initial.endDate); + } + + void changeWeek(DateTime startDate, DateTime endDate) { + final current = innerState ?? fromNothing(); + if (current.startDate == startDate && current.endDate == endDate) return; + add(Emit((s) => s.copyWith(startDate: startDate, endDate: endDate))); + runInSession(() { + _loadWeek(startDate, endDate); + _prefetchAdjacentWeeks(startDate, endDate); + }); + } + + void refresh() => fetch(); + + Future _loadWeek( + DateTime startDate, + DateTime endDate, { + void Function(Object)? onError, + }) async { + final requestStart = DateTime.now(); + _lastWeekRequestStart = requestStart; + try { + final week = await fetchWeek(startDate, endDate); + if (_lastWeekRequestStart.isAfter(requestStart)) return; + _writeWeekToCache(startDate, week); + } catch (e) { + log('Week load error for $startDate–$endDate: $e'); + onError?.call(e); + } + } + + Future _loadReferenceData({void Function(Object)? onError}) async { + try { + final (rooms, subjects, schoolHolidays, schoolyear) = await ( + referenceData.getRooms(onError: onError), + referenceData.getSubjects(onError: onError), + referenceData.getSchoolHolidays(onError: onError), + referenceData.getCurrentSchoolyear(onError: onError), + ).wait; + + add( + Emit( + (s) => s.withReferenceData( + rooms: rooms, + subjects: subjects, + schoolHolidays: schoolHolidays, + schoolyear: schoolyear, + ), + ), + ); + } catch (e) { + onError?.call(e); + } + + try { + final timegrid = await referenceData.getTimegrid(); + add(Emit((s) => s.withReferenceData(timegrid: timegrid))); + } catch (_) { + // Timegrid load failure falls back to a hardcoded schedule in the UI. + } + } + + void _prefetchAdjacentWeeks(DateTime start, DateTime end) { + _prefetchWeek(start.subtractDays(7), end.subtractDays(7)); + _prefetchWeek(start.addDays(7), end.addDays(7)); + } + + void _prefetchWeek(DateTime start, DateTime end) { + fetchWeek( + start, + end, + ).then((week) => _writeWeekToCache(start, week)).catchError((_) {}); + } + + void _writeWeekToCache(DateTime weekStart, TimetableGetWeekResponse week) { + add(Emit((s) => s.withFetchedWeek(weekStart, week))); + } +} 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 dd38d2c..ca9bf35 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 @@ -23,72 +23,31 @@ import '../../../../../api/mhsl/custom_timetable_event/get/get_custom_timetable_ import '../../../../../api/request_cache.dart'; /// Pulls the timetable from the Marianum-Connect mobile API. Each endpoint is -/// its own HTTP call; this provider exposes the lazy futures so the bloc can -/// chain them without seeing the dio layer. +/// its own HTTP call; this provider exposes the futures so the bloc can chain +/// them without seeing the dio layer. class TimetableDataProvider { Future getWeek( DateTime startDate, - DateTime endDate, { - void Function(Object)? onError, - bool renew = false, - }) async { - try { - return await TimetableGetWeek().run(from: startDate, until: endDate); - } catch (e) { - onError?.call(e); - rethrow; - } - } + DateTime endDate, + ) async => TimetableGetWeek().run(from: startDate, until: endDate); Future getRooms({ void Function(Object)? onError, - bool renew = false, - }) async { - try { - return await TimetableGetRooms().run(); - } catch (e) { - onError?.call(e); - rethrow; - } - } + }) => _reportingErrors(TimetableGetRooms().run, onError); Future getSubjects({ void Function(Object)? onError, - bool renew = false, - }) async { - try { - return await TimetableGetSubjects().run(); - } catch (e) { - onError?.call(e); - rethrow; - } - } + }) => _reportingErrors(TimetableGetSubjects().run, onError); Future getSchoolHolidays({ void Function(Object)? onError, - bool renew = false, - }) async { - try { - return await TimetableGetHolidays().run(); - } catch (e) { - onError?.call(e); - rethrow; - } - } + }) => _reportingErrors(TimetableGetHolidays().run, onError); Future getCurrentSchoolyear({ void Function(Object)? onError, - bool renew = false, - }) async { - try { - return await TimetableGetSchoolyear().run(); - } catch (e) { - onError?.call(e); - rethrow; - } - } + }) => _reportingErrors(TimetableGetSchoolyear().run, onError); - Future getTimegrid({bool renew = false}) => + Future getTimegrid() => TimetableGetTimegrid().run(); Future getCustomEvents({ @@ -131,4 +90,18 @@ class TimetableDataProvider { if (DemoMode.active) return Future.value(); return TimetableSubjectColorRemove().run(subjectShort); } + + // Reports the original error before rethrowing: callers await several of + // these together, and a parallel wait only surfaces a combined error. + static Future _reportingErrors( + Future Function() request, + void Function(Object)? onError, + ) async { + try { + return await request(); + } catch (e) { + onError?.call(e); + rethrow; + } + } } diff --git a/lib/storage/dev_tools_settings.dart b/lib/storage/dev_tools_settings.dart index b9d8be2..92104dc 100644 --- a/lib/storage/dev_tools_settings.dart +++ b/lib/storage/dev_tools_settings.dart @@ -88,14 +88,10 @@ class DevToolsSettings { if (trimmed.isEmpty) return null; final uri = Uri.tryParse(trimmed); if (uri == null || !uri.hasScheme) return null; - if (uri.scheme == 'https') { - // always fine - } else if (uri.scheme == 'http' && allowsHttpCustomEndpoint) { - // dev only - } else { - return null; - } - if (uri.host.isEmpty) return null; + final schemeAllowed = + uri.scheme == 'https' || + (uri.scheme == 'http' && allowsHttpCustomEndpoint); + if (!schemeAllowed || uri.host.isEmpty) return null; return trimmed.endsWith('/') ? trimmed.substring(0, trimmed.length - 1) : trimmed; diff --git a/lib/storage/modules_settings.dart b/lib/storage/modules_settings.dart index a3bc941..fa74c75 100644 --- a/lib/storage/modules_settings.dart +++ b/lib/storage/modules_settings.dart @@ -1,6 +1,6 @@ -import 'package:freezed_annotation/freezed_annotation.dart'; +import 'package:json_annotation/json_annotation.dart'; -import '../../state/app/modules/app_modules.dart'; +import '../state/app/modules/app_modules.dart'; part 'modules_settings.g.dart'; diff --git a/lib/storage/timetable_favorites_settings.dart b/lib/storage/timetable_favorites_settings.dart index 9cba86d..ee21d4b 100644 --- a/lib/storage/timetable_favorites_settings.dart +++ b/lib/storage/timetable_favorites_settings.dart @@ -34,8 +34,7 @@ class TimetableFavoritesSettings { /// Adds or removes the element in place. Returns the new favorite state /// (`true` = now starred). Callers persist via `SettingsCubit.val(write: true)`. bool toggle(TimetableElementType type, int id, String label) { - final existing = favorites.where((f) => f.type == type && f.id == id); - if (existing.isNotEmpty) { + if (isFavorite(type, id)) { favorites.removeWhere((f) => f.type == type && f.id == id); return false; } diff --git a/lib/storage/timetable_settings.dart b/lib/storage/timetable_settings.dart index feaa007..7c288ab 100644 --- a/lib/storage/timetable_settings.dart +++ b/lib/storage/timetable_settings.dart @@ -1,6 +1,6 @@ import 'package:json_annotation/json_annotation.dart'; -import '../../../view/pages/timetable/data/timetable_name_mode.dart'; +import '../view/pages/timetable/data/timetable_name_mode.dart'; part 'timetable_settings.g.dart'; diff --git a/lib/theming/app_theme.dart b/lib/theming/app_theme.dart index 4acb96f..a50cfa2 100644 --- a/lib/theming/app_theme.dart +++ b/lib/theming/app_theme.dart @@ -10,31 +10,21 @@ class AppSpacing { static const double xl = 40; } -TextStyle inputErrorStyle(BuildContext context) => - TextStyle(color: Theme.of(context).colorScheme.error); - class AppTheme { - static DropdownDisplay getDisplayOptions(ThemeMode theme) { - switch (theme) { - case ThemeMode.system: - return DropdownDisplay( - icon: Icons.auto_fix_high_outlined, - displayName: 'Systemvorgabe', - ); - - case ThemeMode.light: - return DropdownDisplay( - icon: Icons.wb_sunny_outlined, - displayName: 'Hell', - ); - - case ThemeMode.dark: - return DropdownDisplay( - icon: Icons.dark_mode_outlined, - displayName: 'Dunkel', - ); - } - } + static DropdownDisplay getDisplayOptions(ThemeMode theme) => switch (theme) { + ThemeMode.system => DropdownDisplay( + icon: Icons.auto_fix_high_outlined, + displayName: 'Systemvorgabe', + ), + ThemeMode.light => DropdownDisplay( + icon: Icons.wb_sunny_outlined, + displayName: 'Hell', + ), + ThemeMode.dark => DropdownDisplay( + icon: Icons.dark_mode_outlined, + displayName: 'Dunkel', + ), + }; static bool isDarkMode(BuildContext context) => Theme.of(context).brightness == Brightness.dark; diff --git a/lib/utils/downloads/download_job.dart b/lib/utils/downloads/download_job.dart index 93423fa..549c54a 100644 --- a/lib/utils/downloads/download_job.dart +++ b/lib/utils/downloads/download_job.dart @@ -35,11 +35,7 @@ class DownloadFailed extends DownloadStatus { /// the same job. Kept engine-agnostic on purpose — the native /// `background_downloader` task lives entirely inside [DownloadManager]. class DownloadJob { - DownloadJob({ - required this.remotePath, - required this.name, - this.remoteFile, - }); + DownloadJob({required this.remotePath, required this.name, this.remoteFile}); final String remotePath; final String name; @@ -59,10 +55,7 @@ class DownloadJob { const DownloadInProgress(0), ); - bool get isFinished => - status.value is DownloadDone || - status.value is DownloadFailed || - status.value is DownloadCancelled; + bool get isFinished => status.value is! DownloadInProgress; bool get isDone => status.value is DownloadDone; bool get isFailed => status.value is DownloadFailed; diff --git a/lib/utils/downloads/download_manager.dart b/lib/utils/downloads/download_manager.dart index d7f2605..b10dac2 100644 --- a/lib/utils/downloads/download_manager.dart +++ b/lib/utils/downloads/download_manager.dart @@ -237,9 +237,7 @@ class DownloadManager { // instead of freezing at the last percent. job.status.value = const DownloadCancelled(); } - _jobs.remove(job.remotePath); - if (taskId != null) _byTaskId.remove(taskId); - scheduleMicrotask(job.dispose); + _forget(job); } _refreshVisible(); } @@ -330,10 +328,14 @@ class DownloadManager { // --- bookkeeping ----------------------------------------------------------- void _remove(DownloadJob job) { + _forget(job); + _refreshVisible(); + } + + void _forget(DownloadJob job) { _jobs.remove(job.remotePath); final taskId = job.taskId; if (taskId != null) _byTaskId.remove(taskId); - _refreshVisible(); scheduleMicrotask(job.dispose); } diff --git a/lib/utils/file_clipboard.dart b/lib/utils/file_clipboard.dart index a8abda3..0ee8cab 100644 --- a/lib/utils/file_clipboard.dart +++ b/lib/utils/file_clipboard.dart @@ -21,16 +21,15 @@ class FileClipboard extends ChangeNotifier { List get files => List.unmodifiable(_files); bool get isEmpty => _files.isEmpty; - void cut(List files) { - if (files.isEmpty) return; - _operation = FileClipboardOperation.cut; - _files = List.of(files); - notifyListeners(); - } + void cut(List files) => + _set(FileClipboardOperation.cut, files); - void copy(List files) { + void copy(List files) => + _set(FileClipboardOperation.copy, files); + + void _set(FileClipboardOperation operation, List files) { if (files.isEmpty) return; - _operation = FileClipboardOperation.copy; + _operation = operation; _files = List.of(files); notifyListeners(); } diff --git a/lib/utils/random_id.dart b/lib/utils/random_id.dart index ffbb21a..bfb3a92 100644 --- a/lib/utils/random_id.dart +++ b/lib/utils/random_id.dart @@ -8,12 +8,3 @@ String randomHexId({int bytes = 16}) { (_) => random.nextInt(256), ).map((b) => b.toRadixString(16).padLeft(2, '0')).join(); } - -/// Random RFC 4122 version-4 UUID, for ids a server expects in UUID form. -String randomUuidV4() { - final hex = randomHexId(); - final variant = (int.parse(hex[16], radix: 16) & 0x3 | 0x8).toRadixString(16); - return '${hex.substring(0, 8)}-${hex.substring(8, 12)}-' - '4${hex.substring(13, 16)}-$variant${hex.substring(17, 20)}-' - '${hex.substring(20)}'; -} diff --git a/lib/view/login/widgets/login_card.dart b/lib/view/login/widgets/login_card.dart index 23703cc..07a90fb 100644 --- a/lib/view/login/widgets/login_card.dart +++ b/lib/view/login/widgets/login_card.dart @@ -32,25 +32,14 @@ class _LoginCardState extends State { final _passwordController = TextEditingController(); final _passwordFocus = FocusNode(); - @override - void initState() { - super.initState(); - widget.controller.addListener(_onControllerChange); - } - @override void dispose() { - widget.controller.removeListener(_onControllerChange); _usernameController.dispose(); _passwordController.dispose(); _passwordFocus.dispose(); super.dispose(); } - void _onControllerChange() { - if (mounted) setState(() {}); - } - String? _required(String? value) => (value ?? '').trim().isEmpty ? 'Eingabe erforderlich' : null; @@ -81,7 +70,10 @@ class _LoginCardState extends State { } @override - Widget build(BuildContext context) { + Widget build(BuildContext context) => + ListenableBuilder(listenable: widget.controller, builder: _buildForm); + + Widget _buildForm(BuildContext context, Widget? _) { final theme = Theme.of(context); final loading = widget.controller.loading; return Form( diff --git a/lib/view/pages/absence_report/absence_report_view.dart b/lib/view/pages/absence_report/absence_report_view.dart index cf0aa6c..fb6b935 100644 --- a/lib/view/pages/absence_report/absence_report_view.dart +++ b/lib/view/pages/absence_report/absence_report_view.dart @@ -30,6 +30,12 @@ class _AbsenceReportViewState extends State { final TextEditingController _lastName = TextEditingController(); final TextEditingController _phone = TextEditingController(); final TextEditingController _note = TextEditingController(); + late final List _textControllers = [ + _firstName, + _lastName, + _phone, + _note, + ]; final AsyncActionController _submitController = AsyncActionController(); late Future _init; @@ -46,7 +52,7 @@ class _AbsenceReportViewState extends State { final today = DateUtils.dateOnly(DateTime.now()); _absentFrom = today; _absentUntil = today; - for (final c in [_firstName, _lastName, _phone, _note]) { + for (final c in _textControllers) { c.addListener(_onFieldChanged); } _init = _load(); @@ -54,7 +60,7 @@ class _AbsenceReportViewState extends State { @override void dispose() { - for (final c in [_firstName, _lastName, _phone, _note]) { + for (final c in _textControllers) { c.dispose(); } _submitController.dispose(); @@ -290,13 +296,11 @@ class _AbsenceReportViewState extends State { String? _requiredError(TextEditingController controller) => _submitted && controller.text.trim().isEmpty ? _required : null; - InputDecoration _decoration(String label, {String? error, String? hint}) => - InputDecoration( - border: const OutlineInputBorder(), - labelText: label, - hintText: hint, - errorText: error, - ); + InputDecoration _decoration(String label, {String? error}) => InputDecoration( + border: const OutlineInputBorder(), + labelText: label, + errorText: error, + ); } /// Terminal success screen shown in place of the form after a report was diff --git a/lib/view/pages/files/data/file_sort.dart b/lib/view/pages/files/data/file_sort.dart new file mode 100644 index 0000000..8850f75 --- /dev/null +++ b/lib/view/pages/files/data/file_sort.dart @@ -0,0 +1,25 @@ +import '../../../../api/marianumcloud/webdav/queries/list_files/cacheable_file.dart'; +import '../../../../api/marianumcloud/webdav/queries/list_files/list_files_response.dart'; +import '../../../../state/app/modules/settings/bloc/settings_cubit.dart'; +import 'sort_options.dart'; + +typedef FileSort = ({SortOption by, bool ascending, bool foldersToTop}); + +/// Selector for `context.select`, so folder views rebuild only when the +/// file sort settings change. +FileSort fileSortOf(SettingsCubit cubit) { + final settings = cubit.state.fileSettings; + return ( + by: settings.sortBy, + ascending: settings.ascending, + foldersToTop: settings.sortFoldersToTop, + ); +} + +extension FileSortListing on ListFilesResponse { + List sortedBy(FileSort sort) => sortBy( + sortOption: sort.by, + foldersToTop: sort.foldersToTop, + reversed: sort.ascending, + ); +} diff --git a/lib/view/pages/files/data/file_type_icon.dart b/lib/view/pages/files/data/file_type_icon.dart index 259d34e..02e5652 100644 --- a/lib/view/pages/files/data/file_type_icon.dart +++ b/lib/view/pages/files/data/file_type_icon.dart @@ -11,12 +11,7 @@ IconData iconForFile(CacheableFile file) { final byMime = _iconForMime(mime); if (byMime != null) return byMime; } - final ext = _extensionOf(file.name); - if (ext != null) { - final byExt = _extensionIcons[ext]; - if (byExt != null) return byExt; - } - return Icons.insert_drive_file_outlined; + return iconForFileName(file.name); } /// Icon for a bare file name (no [CacheableFile] / MIME available), e.g. in the diff --git a/lib/view/pages/files/data/webdav_path.dart b/lib/view/pages/files/data/webdav_path.dart new file mode 100644 index 0000000..4f2cb1d --- /dev/null +++ b/lib/view/pages/files/data/webdav_path.dart @@ -0,0 +1,26 @@ +// Paths relative to the WebDAV root, as used by `CacheableFile.path`: no +// leading slash, folders end with '/', the root folder is ''. + +final RegExp _edgeSlashes = RegExp(r'^/+|/+$'); + +List webdavSegmentsOf(String path) { + final stripped = path.replaceAll(_edgeSlashes, ''); + return stripped.isEmpty ? [] : stripped.split('/'); +} + +List webdavParentSegmentsOf(String path) { + final segments = webdavSegmentsOf(path); + if (segments.isNotEmpty) segments.removeLast(); + return segments; +} + +String webdavFolderPath(List segments) => + segments.isEmpty ? '' : '${segments.join('/')}/'; + +String webdavJoin(String folder, String name, {required bool isDirectory}) => + isDirectory ? '$folder$name/' : '$folder$name'; + +/// Folder key used by `ListFilesCache` and `FilesBloc`: no leading or +/// trailing slash, root is '/'. +String listFilesCacheKey(List segments) => + segments.isEmpty ? '/' : segments.join('/'); diff --git a/lib/view/pages/files/files.dart b/lib/view/pages/files/files.dart index ffcacf4..8e4e87e 100644 --- a/lib/view/pages/files/files.dart +++ b/lib/view/pages/files/files.dart @@ -11,10 +11,10 @@ import '../../../state/app/infrastructure/loadable_state/view/loadable_state_con import '../../../state/app/infrastructure/utility_widgets/bloc_module.dart'; import '../../../state/app/modules/files/bloc/files_bloc.dart'; import '../../../state/app/modules/files/bloc/files_state.dart'; -import '../../../state/app/modules/settings/bloc/settings_cubit.dart'; import '../../../utils/cache_invalidation_bus.dart'; import '../../../widget/placeholder_view.dart'; -import 'data/sort_options.dart'; +import 'data/file_sort.dart'; +import 'data/webdav_path.dart'; import 'files_upload_dialog.dart'; import 'search/files_search_delegate.dart'; import 'widgets/add_file_menu.dart'; @@ -44,9 +44,6 @@ class _FilesView extends StatefulWidget { } class _FilesViewState extends State<_FilesView> { - late final SettingsCubit settings; - late SortOption currentSort; - late bool currentSortDirection; late final StreamSubscription _invalidationSub; // The list builder also runs for loading flips and parent rebuilds; only a @@ -54,32 +51,16 @@ class _FilesViewState extends State<_FilesView> { Object? _sortedKey; List _sortedFiles = const []; - List _sorted(ListFilesResponse listing, bool foldersToTop) { - final key = (listing, currentSort, currentSortDirection, foldersToTop); + List _sorted(ListFilesResponse listing, FileSort sort) { + final key = (listing, sort); if (key == _sortedKey) return _sortedFiles; _sortedKey = key; - return _sortedFiles = listing.sortBy( - sortOption: currentSort, - foldersToTop: foldersToTop, - reversed: currentSortDirection, - ); + return _sortedFiles = listing.sortedBy(sort); } - // Cache key in FilesBloc's pathString format: '/' for root, otherwise - // segments joined without leading/trailing slash. - String get _myPathString => widget.path.isEmpty ? '/' : widget.path.join('/'); - - // Relative folder path matching the WebDAV format used by `CacheableFile.path` - // (no leading slash; trailing slash for non-root). Empty string means root. - String get _currentFolderPath => - widget.path.isEmpty ? '' : '${widget.path.join('/')}/'; - @override void initState() { super.initState(); - settings = context.read(); - currentSort = settings.val().fileSettings.sortBy; - currentSortDirection = settings.val().fileSettings.ascending; _invalidationSub = CacheInvalidationBus.listFilesStream.listen( _onInvalidation, ); @@ -87,7 +68,7 @@ class _FilesViewState extends State<_FilesView> { void _onInvalidation(String invalidatedPath) { if (!mounted) return; - if (invalidatedPath != _myPathString) return; + if (invalidatedPath != listFilesCacheKey(widget.path)) return; context.read().refresh(); } @@ -118,29 +99,12 @@ class _FilesViewState extends State<_FilesView> { final bloc = context.read(); // Selected here, not in the LoadableStateConsumer child: that closure runs // during the consumer's build, where this context may not subscribe. - final foldersToTop = context.select( - (SettingsCubit c) => c.state.fileSettings.sortFoldersToTop, - ); + final sort = context.select(fileSortOf); return Scaffold( appBar: AppBar( title: Text(widget.path.isNotEmpty ? widget.path.last : 'Dateien'), actions: [ - FilesSortActions( - currentSort: currentSort, - ascending: currentSortDirection, - onDirectionChanged: (e) { - setState(() { - currentSortDirection = e; - settings.val(write: true).fileSettings.ascending = e; - }); - }, - onSortChanged: (e) { - setState(() { - currentSort = e; - settings.val(write: true).fileSettings.sortBy = e; - }); - }, - ), + const FilesSortActions(), IconButton( tooltip: 'Suchen', icon: const Icon(Icons.search), @@ -162,7 +126,7 @@ class _FilesViewState extends State<_FilesView> { body: Column( children: [ ClipboardBanner( - currentFolder: _currentFolderPath, + currentFolder: webdavFolderPath(widget.path), onPasteDone: bloc.refresh, ), Expanded( @@ -176,7 +140,7 @@ class _FilesViewState extends State<_FilesView> { text: 'Der Ordner ist leer', ); } - final files = _sorted(listing, foldersToTop); + final files = _sorted(listing, sort); return ListView.builder( padding: EdgeInsets.zero, itemCount: files.length, diff --git a/lib/view/pages/files/files_upload_dialog.dart b/lib/view/pages/files/files_upload_dialog.dart index 2b63619..a3fe308 100644 --- a/lib/view/pages/files/files_upload_dialog.dart +++ b/lib/view/pages/files/files_upload_dialog.dart @@ -28,13 +28,15 @@ class FilesUploadDialog extends StatefulWidget { } class UploadableFile { - TextEditingController fileNameController = TextEditingController(); - String filePath; - String fileName; + final String filePath; + final TextEditingController fileNameController; double? _uploadProgress; bool isConflicting = false; - UploadableFile(this.filePath, this.fileName); + UploadableFile(this.filePath, String fileName) + : fileNameController = TextEditingController(text: fileName); + + String get fileName => fileNameController.text; } class _FilesUploadDialogState extends State { @@ -47,10 +49,22 @@ class _FilesUploadDialogState extends State { void initState() { super.initState(); - _uploadableFiles = widget.filePaths.map((filePath) { - var fileName = filePath.split(Platform.pathSeparator).last; - return UploadableFile(filePath, fileName); - }).toList(); + _uploadableFiles = widget.filePaths + .map( + (filePath) => UploadableFile( + filePath, + filePath.split(Platform.pathSeparator).last, + ), + ) + .toList(); + } + + @override + void dispose() { + for (final file in _uploadableFiles) { + file.fileNameController.dispose(); + } + super.dispose(); } void _resetProgress() { @@ -69,6 +83,10 @@ class _FilesUploadDialogState extends State { ); } + void _clearConflict(UploadableFile file) { + if (file.isConflicting) setState(() => file.isConflicting = false); + } + Future uploadFiles({bool override = false}) async { setState(() { _isUploading = true; @@ -82,36 +100,39 @@ class _FilesUploadDialogState extends State { if (!mounted) return; if (!override) { - List result; + final List existingPaths; try { - result = (await webdavClient.propfind( + final result = await webdavClient.propfind( PathUri.parse(widget.remotePath), - )).responses; + ); + existingPaths = [ + for (final response in result.responses) + Uri.decodeComponent(response.href!), + ]; } catch (e) { if (!mounted) return; _showUploadError('Verbindung fehlgeschlagen: $e'); return; } - final conflictingFiles = _uploadableFiles.where((file) { - final fileName = file.fileName; - return result.any( - (element) => Uri.decodeComponent( - (element as WebDavResponse).href!, - ).endsWith('/$fileName'), - ); - }).toList(); + final conflictingFiles = _uploadableFiles + .where( + (file) => existingPaths.any( + (path) => path.endsWith('/${file.fileName}'), + ), + ) + .toList(); if (conflictingFiles.isNotEmpty) { if (!mounted) return; final replaceFiles = await showDialog( context: context, barrierDismissible: false, - builder: (context) => AlertDialog( + builder: (dialogContext) => AlertDialog( contentPadding: const EdgeInsets.all(10), title: const Text('Konflikt', textAlign: TextAlign.center), content: conflictingFiles.length == 1 ? Text( - 'Eine Datei mit dem Namen "${conflictingFiles.map((e) => e.fileName).first}" existiert bereits.', + 'Eine Datei mit dem Namen "${conflictingFiles.first.fileName}" existiert bereits.', textAlign: TextAlign.left, ) : SingleChildScrollView( @@ -122,27 +143,20 @@ class _FilesUploadDialogState extends State { ), actions: [ TextButton( - onPressed: () { - Navigator.pop(context, false); - }, + onPressed: () => Navigator.pop(dialogContext, false), child: const Text('Bearbeiten', textAlign: TextAlign.center), ), TextButton( - onPressed: () { - showDialog( - context: context, - builder: (context) => ConfirmDialog( - title: 'Bestätigen?', - content: - 'Bist du sicher, dass du ${conflictingFiles.length} Dateien überschreiben möchtest?', - onConfirm: () { - Navigator.pop(context, true); - }, - confirmButton: 'Ja', - cancelButton: 'Nein', - ), - ); - }, + // ConfirmDialog closes itself first, so this pop closes the + // conflict dialog. + onPressed: () => ConfirmDialog( + title: 'Bestätigen?', + content: + 'Bist du sicher, dass du ${conflictingFiles.length} Dateien überschreiben möchtest?', + onConfirm: () => Navigator.pop(dialogContext, true), + confirmButton: 'Ja', + cancelButton: 'Nein', + ).asDialog(dialogContext), child: const Text('Überschreiben', textAlign: TextAlign.center), ), ], @@ -163,9 +177,9 @@ class _FilesUploadDialogState extends State { } var uploadetFilePaths = []; - for (var file in _uploadableFiles) { + for (final (fileIndex, file) in _uploadableFiles.indexed) { var fileName = file.fileName; - var filePath = file.filePath; + final filePath = file.filePath; if (widget.uniqueNames) { final unique = DateTime.now().microsecondsSinceEpoch.toRadixString(36); @@ -177,7 +191,7 @@ class _FilesUploadDialogState extends State { setState(() { _infoText = - '${_uploadableFiles.indexOf(file) + 1}/${_uploadableFiles.length}'; + '${fileIndex + 1}/${_uploadableFiles.length}'; }); // A vanished source file would otherwise surface as a cryptic @@ -193,7 +207,6 @@ class _FilesUploadDialogState extends State { } final HttpClientResponse uploadTask; - final fileIndex = _uploadableFiles.indexOf(file); var lastPercent = -1; try { uploadTask = await webdavClient.putFile( @@ -253,12 +266,6 @@ class _FilesUploadDialogState extends State { itemCount: _uploadableFiles.length, itemBuilder: (context, index) { final currentFile = _uploadableFiles[index]; - // Only sync when it differs: assigning text resets selection - // and notifies the field on every (progress) rebuild. - if (currentFile.fileNameController.text != - currentFile.fileName) { - currentFile.fileNameController.text = currentFile.fileName; - } return ListTile( title: TextField( readOnly: _isUploading, @@ -273,24 +280,11 @@ class _FilesUploadDialogState extends State { color: Theme.of(context).colorScheme.error, ), ), - onChanged: (input) { - currentFile.fileName = input; - }, onTapOutside: (PointerDownEvent event) { FocusBehaviour.textFieldTapOutside(context); - if (currentFile.isConflicting) { - setState(() { - currentFile.isConflicting = false; - }); - } - }, - onEditingComplete: () { - if (currentFile.isConflicting) { - setState(() { - currentFile.isConflicting = false; - }); - } + _clearConflict(currentFile); }, + onEditingComplete: () => _clearConflict(currentFile), ), subtitle: _isUploading && (currentFile._uploadProgress ?? 0) < 1 @@ -301,21 +295,23 @@ class _FilesUploadDialogState extends State { ), ) : null, - trailing: Container( + trailing: SizedBox( width: 24, height: 24, - padding: EdgeInsets.zero, child: IconButton( tooltip: 'Datei entfernen', padding: EdgeInsets.zero, onPressed: () { if (!_isUploading) { - if (_uploadableFiles.length - 1 <= 0) { + if (_uploadableFiles.length == 1) { Navigator.of(context).pop(); } - setState(() { - _uploadableFiles.removeAt(index); - }); + final removed = _uploadableFiles[index]; + setState(() => _uploadableFiles.removeAt(index)); + // Its TextField is still mounted until the rebuild. + WidgetsBinding.instance.addPostFrameCallback( + (_) => removed.fileNameController.dispose(), + ); } }, icon: const Icon(Icons.delete_outlined), @@ -342,7 +338,7 @@ class _FilesUploadDialogState extends State { child: const Text('Abbrechen'), ), ), - const Expanded(child: SizedBox.shrink()), + const Spacer(), Visibility( visible: _isUploading, replacement: TextButton( diff --git a/lib/view/pages/files/search/files_search_controller.dart b/lib/view/pages/files/search/files_search_controller.dart index 1086a20..c3dc5f6 100644 --- a/lib/view/pages/files/search/files_search_controller.dart +++ b/lib/view/pages/files/search/files_search_controller.dart @@ -3,6 +3,7 @@ import 'package:flutter/foundation.dart'; import '../../../../api/marianumcloud/search/search_files.dart'; import '../../../../api/marianumcloud/webdav/queries/list_files/cacheable_file.dart'; import '../../../../utils/debouncer.dart'; +import '../data/webdav_path.dart'; import 'local_cache_search.dart'; /// Holds the live state of a Files-search session: current query, the latest @@ -28,12 +29,11 @@ class FilesSearchController extends ChangeNotifier { bool _disposed = false; Future>? _localIndex; - Future> _searchLocal(List pathScope) async => - searchLocalCacheIndex( - await (_localIndex ??= loadLocalCacheIndex()), - _query, - pathScope: pathScope, - ); + Future> _searchLocal() async => searchLocalCacheIndex( + await (_localIndex ??= loadLocalCacheIndex()), + _query, + pathScope: _pathScope, + ); /// Guards against the race where the search delegate is closed (and the /// controller disposed) while a debounced cache scan or server call is @@ -45,10 +45,7 @@ class FilesSearchController extends ChangeNotifier { } String get query => _query; - List get pathScope => List.unmodifiable(_pathScope); bool get isScoped => _pathScope.isNotEmpty; - List get cacheResults => _cacheResults; - List get serverResults => _serverResults; bool get serverLoading => _serverLoading; Object? get serverError => _serverError; @@ -81,18 +78,7 @@ class FilesSearchController extends ChangeNotifier { _safeNotify(); return; } - // Show loading immediately — even before the (typically fast) cache - // scan resolves — so the indicator is visible the moment the user - // starts typing rather than after the first await hop. - _serverLoading = true; - _serverError = null; - _safeNotify(); - - final cacheHits = await _searchLocal(_pathScope); - if (epoch != _serverEpoch) return; - _cacheResults = cacheHits; - _safeNotify(); - _scheduleServerCall(); + await _searchLocalThenServer(epoch); } /// Drops the path filter and re-runs the current search globally. Used by @@ -105,11 +91,18 @@ class FilesSearchController extends ChangeNotifier { _safeNotify(); return; } + await _searchLocalThenServer(epoch); + } + + Future _searchLocalThenServer(int epoch) async { + // Show loading immediately — even before the (typically fast) cache + // scan resolves — so the indicator is visible the moment the user + // starts typing rather than after the first await hop. _serverLoading = true; _serverError = null; _safeNotify(); - final cacheHits = await _searchLocal(const []); + final cacheHits = await _searchLocal(); if (epoch != _serverEpoch) return; _cacheResults = cacheHits; _safeNotify(); @@ -135,7 +128,7 @@ class FilesSearchController extends ChangeNotifier { Future _runServerCall() async { final epoch = _serverEpoch; final term = _query; - final scopePrefix = _pathScope.isEmpty ? '' : '${_pathScope.join('/')}/'; + final scopePrefix = webdavFolderPath(_pathScope); try { final response = await _api.run(term: term); if (epoch != _serverEpoch) return; diff --git a/lib/view/pages/files/search/files_search_delegate.dart b/lib/view/pages/files/search/files_search_delegate.dart index f7e6d84..dc38ab7 100644 --- a/lib/view/pages/files/search/files_search_delegate.dart +++ b/lib/view/pages/files/search/files_search_delegate.dart @@ -38,13 +38,7 @@ class FilesSearchDelegate extends SearchDelegate { ); @override - Widget buildResults(BuildContext context) { - _controller.setQuery(query); - return FilesSearchResults( - controller: _controller, - onResultTap: () => close(context, null), - ); - } + Widget buildResults(BuildContext context) => buildSuggestions(context); @override Widget buildSuggestions(BuildContext context) { diff --git a/lib/view/pages/files/search/files_search_results.dart b/lib/view/pages/files/search/files_search_results.dart index 69db680..022675f 100644 --- a/lib/view/pages/files/search/files_search_results.dart +++ b/lib/view/pages/files/search/files_search_results.dart @@ -11,6 +11,7 @@ import '../../../../state/app/infrastructure/loadable_state/view/loadable_state_ import '../../../../state/app/infrastructure/loadable_state/view/loadable_state_primary_loading.dart'; import '../../../../state/app/infrastructure/utility_widgets/bloc_module.dart'; import '../../../../widget/placeholder_view.dart'; +import '../data/webdav_path.dart'; import '../widgets/file_element.dart'; import 'files_search_controller.dart'; @@ -80,8 +81,8 @@ class FilesSearchResults extends StatelessWidget { visible: showErrorScreen, message: errorMessage, ), - if (showEmpty) _emptyState(context), - if (hasContent) _resultList(context, combined), + if (showEmpty) _emptyState(), + if (hasContent) _resultList(combined), ], ), ), @@ -89,7 +90,7 @@ class FilesSearchResults extends StatelessWidget { ); } - Widget _emptyState(BuildContext context) => PlaceholderView( + Widget _emptyState() => PlaceholderView( icon: Icons.search_off_outlined, text: 'Keine Treffer gefunden.', button: controller.isScoped @@ -101,7 +102,7 @@ class FilesSearchResults extends StatelessWidget { : null, ); - Widget _resultList(BuildContext context, List combined) { + Widget _resultList(List combined) { final groups = _groupByParent(combined); final orderedKeys = groups.keys.toList()..sort(); // Flat (folder header | file) rows built lazily: results can run into @@ -117,7 +118,7 @@ class FilesSearchResults extends StatelessWidget { itemCount: rows.length, itemBuilder: (context, index) { final (folder, file) = rows[index]; - final segments = _segmentsOf(folder); + final segments = webdavSegmentsOf(folder); if (file == null) { return _FolderHeader( key: ValueKey('folder:$folder'), @@ -139,29 +140,14 @@ class FilesSearchResults extends StatelessWidget { ); } - static final RegExp _edgeSlashes = RegExp(r'^/+|/+$'); - Map> _groupByParent(List files) { final map = >{}; for (final file in files) { - map.putIfAbsent(_parentOf(file), () => []).add(file); + final parent = '/${webdavParentSegmentsOf(file.path).join('/')}'; + map.putIfAbsent(parent, () => []).add(file); } return map; } - - String _parentOf(CacheableFile file) { - final stripped = file.path.replaceAll(_edgeSlashes, ''); - final segments = stripped.split('/'); - if (segments.length <= 1) return '/'; - segments.removeLast(); - return '/${segments.join('/')}'; - } - - List _segmentsOf(String folder) { - final stripped = folder.replaceAll(_edgeSlashes, ''); - if (stripped.isEmpty) return const []; - return stripped.split('/'); - } } class _FolderHeader extends StatelessWidget { diff --git a/lib/view/pages/files/search/local_cache_search.dart b/lib/view/pages/files/search/local_cache_search.dart index ea090da..11e14ec 100644 --- a/lib/view/pages/files/search/local_cache_search.dart +++ b/lib/view/pages/files/search/local_cache_search.dart @@ -5,6 +5,7 @@ import 'package:flutter/foundation.dart'; import '../../../../api/cache_store.dart'; import '../../../../api/marianumcloud/webdav/queries/list_files/cacheable_file.dart'; import '../../../../api/marianumcloud/webdav/queries/list_files/list_files_response.dart'; +import '../data/webdav_path.dart'; /// Document key prefix used by `ListFilesCache._documentId`. const String _folderCachePrefix = 'wd-folder-'; @@ -42,18 +43,16 @@ List buildLocalCacheIndex(List payloads) { /// Files in [index] whose name contains [query] (case-insensitive). /// /// [pathScope] restricts results to entries whose WebDAV path starts with -/// the given folder. Pass an empty list (or null) to search globally. +/// the given folder. Pass an empty list to search globally. List searchLocalCacheIndex( List index, String query, { - List? pathScope, + List pathScope = const [], }) { final trimmed = query.trim(); if (trimmed.isEmpty) return const []; final needle = trimmed.toLowerCase(); - final scopePrefix = pathScope == null || pathScope.isEmpty - ? '' - : '${pathScope.join('/')}/'; + final scopePrefix = webdavFolderPath(pathScope); return [ for (final file in index) if (file.lowerName.contains(needle) && diff --git a/lib/view/pages/files/sharing/share_options_sheet.dart b/lib/view/pages/files/sharing/share_options_sheet.dart index 49fbc9b..81ca9f8 100644 --- a/lib/view/pages/files/sharing/share_options_sheet.dart +++ b/lib/view/pages/files/sharing/share_options_sheet.dart @@ -21,6 +21,18 @@ import 'share_password_sheet.dart'; String _two(int n) => n.toString().padLeft(2, '0'); String _isoDate(DateTime d) => '${d.year}-${_two(d.month)}-${_two(d.day)}'; +/// Token of the Talk room a share points to; null for other share kinds. +String? talkRoomTokenOf(Share share) { + final token = share.shareWith; + return share.isRoom && token != null && token.isNotEmpty ? token : null; +} + +/// Opens the Talk room and closes the sheet it was opened from. +void openShareInTalk(BuildContext context, String token) { + AppRoutes.openChatByToken(context, token); + if (Navigator.of(context).canPop()) Navigator.of(context).pop(); +} + IconData shareIcon(Share share) { if (share.isPublicLink) return Icons.link; if (share.isGroup) return Icons.groups_outlined; @@ -85,6 +97,9 @@ class _ShareOptionsBodyState extends State<_ShareOptionsBody> { } } + Future _update(ShareUpdateParams params) => + _mutate(() => FileSharingApi().update(_share.id, params)); + List _availablePresets() => [ SharePreset.readOnly, SharePreset.edit, @@ -93,15 +108,12 @@ class _ShareOptionsBodyState extends State<_ShareOptionsBody> { Future _setPreset(SharePreset preset) async { final caps = context.read(); - await _mutate( - () => FileSharingApi().update( - _share.id, - ShareUpdateParams( - permissions: permissionsFor( - preset, - allowReshare: caps.canReshare, - isFolder: _share.isFolder, - ), + await _update( + ShareUpdateParams( + permissions: permissionsFor( + preset, + allowReshare: caps.canReshare, + isFolder: _share.isFolder, ), ), ); @@ -121,20 +133,11 @@ class _ShareOptionsBodyState extends State<_ShareOptionsBody> { lastDate: lastDate, ); if (picked == null) return; - await _mutate( - () => FileSharingApi().update( - _share.id, - ShareUpdateParams(expireDate: _isoDate(picked)), - ), - ); + await _update(ShareUpdateParams(expireDate: _isoDate(picked))); } - Future _clearExpiry() => _mutate( - () => FileSharingApi().update( - _share.id, - const ShareUpdateParams(expireDate: ''), - ), - ); + Future _clearExpiry() => + _update(const ShareUpdateParams(expireDate: '')); Future _changePassword() async { final caps = context.read(); @@ -144,27 +147,11 @@ class _ShareOptionsBodyState extends State<_ShareOptionsBody> { policyHint: caps.passwordPolicyHint, ); if (value == null || value.isEmpty || !mounted) return; - await _mutate( - () => FileSharingApi().update( - _share.id, - ShareUpdateParams(password: value), - ), - ); + await _update(ShareUpdateParams(password: value)); } - Future _clearPassword() => _mutate( - () => FileSharingApi().update( - _share.id, - const ShareUpdateParams(password: ''), - ), - ); - - void _openInTalk() { - final token = _share.shareWith; - if (token == null || token.isEmpty) return; - AppRoutes.openChatByToken(context, token); - if (Navigator.of(context).canPop()) Navigator.of(context).pop(); - } + Future _clearPassword() => + _update(const ShareUpdateParams(password: '')); Future _confirmDelete() async { // Use showDialog directly (not asDialog) so we get the AsyncDialogAction's @@ -217,12 +204,12 @@ class _ShareOptionsBodyState extends State<_ShareOptionsBody> { ), ), - if (_share.isRoom && (_share.shareWith?.isNotEmpty ?? false)) ...[ + if (talkRoomTokenOf(_share) case final token?) ...[ const Divider(height: 24), ListTile( leading: const CenteredLeading(Icon(Icons.chat_bubble_outline)), title: const Text('Im Talk-Chat öffnen'), - onTap: _busy ? null : _openInTalk, + onTap: _busy ? null : () => openShareInTalk(context, token), ), ], diff --git a/lib/view/pages/files/sharing/share_sheet.dart b/lib/view/pages/files/sharing/share_sheet.dart index c736259..9108e68 100644 --- a/lib/view/pages/files/sharing/share_sheet.dart +++ b/lib/view/pages/files/sharing/share_sheet.dart @@ -47,19 +47,13 @@ class _ShareSheetBody extends StatefulWidget { class _ShareSheetBodyState extends State<_ShareSheetBody> { late final String _ocsPath = ocsPathOf(widget.file); - Future>? _future; + late Future> _future = FileSharingApi().listForPath(_ocsPath); bool _busy = false; /// Last resolved share list — used by the create-link gate without depending /// on the FutureBuilder's snapshot. List? _lastShares; - @override - void initState() { - super.initState(); - _future = FileSharingApi().listForPath(_ocsPath); - } - void _reload() { // Block body: an arrow would return the Future, which setState rejects. setState(() { @@ -144,26 +138,19 @@ class _ShareSheetBodyState extends State<_ShareSheetBody> { leading: const CenteredLeading(Icon(Icons.person_add_outlined)), title: const Text('Person oder Gruppe hinzufügen'), enabled: !_busy, - onTap: _busy ? null : _addSharee, + onTap: _addSharee, ), if (caps.canCreatePublicLinks && _canAddLink(caps)) ListTile( leading: const CenteredLeading(Icon(Icons.add_link)), title: const Text('Öffentlichen Link erstellen'), enabled: !_busy, - onTap: _busy ? null : _createPublicLink, + onTap: _createPublicLink, ), ], ); } - void _openInTalk(Share share) { - final token = share.shareWith; - if (token == null || token.isEmpty) return; - AppRoutes.openChatByToken(context, token); - if (Navigator.of(context).canPop()) Navigator.of(context).pop(); - } - /// Whether another public link may be created — hidden once one exists on a /// server that disallows multiple links. bool _canAddLink(NextcloudCapabilitiesCubit caps) { @@ -271,11 +258,11 @@ class _ShareSheetBodyState extends State<_ShareSheetBody> { tooltip: 'Link kopieren', onPressed: () => copyToClipboard(context, share.url!), ), - if (share.isRoom && (share.shareWith?.isNotEmpty ?? false)) + if (talkRoomTokenOf(share) case final token?) IconButton( icon: const Icon(Icons.chat_bubble_outline), tooltip: 'Im Talk-Chat öffnen', - onPressed: () => _openInTalk(share), + onPressed: () => openShareInTalk(context, token), ), IconButton( icon: const Icon(Icons.settings_outlined), diff --git a/lib/view/pages/files/widgets/clipboard_banner.dart b/lib/view/pages/files/widgets/clipboard_banner.dart index 8b1a078..001d041 100644 --- a/lib/view/pages/files/widgets/clipboard_banner.dart +++ b/lib/view/pages/files/widgets/clipboard_banner.dart @@ -5,6 +5,7 @@ import '../../../../api/marianumcloud/webdav/queries/list_files/list_files_cache import '../../../../api/marianumcloud/webdav/webdav_api.dart'; import '../../../../utils/file_clipboard.dart'; import '../../../../widget/info_dialog.dart'; +import '../data/webdav_path.dart'; /// Banner that appears at the top of a Files folder while there is something /// in the file clipboard. Shows the cut/copy state and offers a "Hier @@ -26,16 +27,6 @@ class ClipboardBanner extends StatefulWidget { class _ClipboardBannerState extends State { bool _busy = false; - // All paths here are relative to the WebDAV root (matching `CacheableFile.path`). - // Root is the empty string ''. Folders end with '/'. - String _normalised(String path) { - final stripped = path.replaceAll(RegExp(r'^/+|/+$'), ''); - return stripped.isEmpty ? '' : '$stripped/'; - } - - String _joinPath(String folder, String name, {required bool isDirectory}) => - isDirectory ? '$folder$name/' : '$folder$name'; - // Disabled when: // - clipboard is empty // - we'd be pasting a folder into itself or one of its descendants @@ -43,14 +34,14 @@ class _ClipboardBannerState extends State { bool get _canPaste { final cb = FileClipboard.instance; if (cb.isEmpty) return false; - final dst = _normalised(widget.currentFolder); + final dst = webdavFolderPath(webdavSegmentsOf(widget.currentFolder)); var atLeastOneActionable = false; for (final f in cb.files) { if (f.isDirectory) { - final src = _normalised(f.path); + final src = webdavFolderPath(webdavSegmentsOf(f.path)); if (dst == src || dst.startsWith(src)) return false; } - final destination = _joinPath( + final destination = webdavJoin( widget.currentFolder, f.name, isDirectory: f.isDirectory, @@ -60,15 +51,6 @@ class _ClipboardBannerState extends State { return atLeastOneActionable; } - // Cache key format used by ListFilesCache (matches FilesBloc's pathString: - // relative, no leading or trailing slash; root is '/'). - String _parentCacheKey(String relativePath) { - final stripped = relativePath.replaceAll(RegExp(r'^/+|/+$'), ''); - if (!stripped.contains('/')) return '/'; - final parts = stripped.split('/')..removeLast(); - return parts.isEmpty ? '/' : parts.join('/'); - } - Future _paste() async { final cb = FileClipboard.instance; if (_busy || !_canPaste) return; @@ -79,7 +61,7 @@ class _ClipboardBannerState extends State { try { final webdav = await WebdavApi.webdav; for (final file in cb.files) { - final destination = _joinPath( + final destination = webdavJoin( widget.currentFolder, file.name, isDirectory: file.isDirectory, @@ -91,7 +73,9 @@ class _ClipboardBannerState extends State { PathUri.parse(file.path), PathUri.parse(destination), ); - invalidatedSourceFolders.add(_parentCacheKey(file.path)); + invalidatedSourceFolders.add( + listFilesCacheKey(webdavParentSegmentsOf(file.path)), + ); } else { await webdav.copy( PathUri.parse(file.path), @@ -105,9 +89,9 @@ class _ClipboardBannerState extends State { // After cut, the source folders no longer contain the moved files. Drop // their cached listings so the next visit fetches fresh data instead of // briefly showing the moved file as still present. - for (final folder in invalidatedSourceFolders) { - await ListFilesCache.invalidate(folder); - } + await Future.wait( + invalidatedSourceFolders.map(ListFilesCache.invalidate), + ); if (operation == FileClipboardOperation.cut) cb.clear(); widget.onPasteDone(); } finally { diff --git a/lib/view/pages/files/widgets/file_element.dart b/lib/view/pages/files/widgets/file_element.dart index f280b89..9bb54ef 100644 --- a/lib/view/pages/files/widgets/file_element.dart +++ b/lib/view/pages/files/widgets/file_element.dart @@ -19,6 +19,7 @@ import '../../../../widget/details_bottom_sheet.dart'; import '../../../../widget/downloads/download_trigger.dart'; import '../../../../widget/prompt_dialog.dart'; import '../../talk/widgets/highlighted_linkify.dart'; +import '../data/webdav_path.dart'; import '../sharing/share_sheet.dart'; import 'file_details_sheet.dart'; import 'file_leading.dart'; @@ -26,7 +27,7 @@ import 'file_leading.dart'; class FileElement extends StatefulWidget { final CacheableFile file; final List path; - final void Function() refetch; + final VoidCallback refetch; /// When non-null, occurrences of this string in the file name are visually /// highlighted in the tile title. Used by the Files search delegate. @@ -101,7 +102,7 @@ class _FileElementState extends State if (widget.file.isDirectory) { AppRoutes.openFolder( context, - widget.path.toList()..add(widget.file.name), + [...widget.path, widget.file.name], ); return; } @@ -116,18 +117,6 @@ class _FileElementState extends State ); } - // All paths here are relative to the WebDAV root (matching CacheableFile.path). - // Root parent is the empty string ''. Folders end with '/'. - String _parentPathOf(String path) { - final stripped = path.replaceAll(RegExp(r'^/+|/+$'), ''); - if (!stripped.contains('/')) return ''; - final parts = stripped.split('/')..removeLast(); - return parts.isEmpty ? '' : '${parts.join('/')}/'; - } - - String _joinPath(String folder, String name, {required bool isDirectory}) => - isDirectory ? '$folder$name/' : '$folder$name'; - void _rename() { if (guardDemoAction(context)) return; showPromptDialog( @@ -138,9 +127,8 @@ class _FileElementState extends State initialValue: widget.file.name, onConfirm: (newName) async { if (newName.isEmpty || newName == widget.file.name) return; - final parent = _parentPathOf(widget.file.path); - final destination = _joinPath( - parent, + final destination = webdavJoin( + webdavFolderPath(webdavParentSegmentsOf(widget.file.path)), newName, isDirectory: widget.file.isDirectory, ); diff --git a/lib/view/pages/files/widgets/files_sort_actions.dart b/lib/view/pages/files/widgets/files_sort_actions.dart index 2ff1631..57a90f7 100644 --- a/lib/view/pages/files/widgets/files_sort_actions.dart +++ b/lib/view/pages/files/widgets/files_sort_actions.dart @@ -1,24 +1,22 @@ import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import '../../../../state/app/modules/settings/bloc/settings_cubit.dart'; import '../data/sort_options.dart'; +/// Sort direction and sort key pickers, read from and persisted to the file +/// settings. class FilesSortActions extends StatelessWidget { - final SortOption currentSort; - final bool ascending; - final ValueChanged onDirectionChanged; - final ValueChanged onSortChanged; - - const FilesSortActions({ - required this.currentSort, - required this.ascending, - required this.onDirectionChanged, - required this.onSortChanged, - super.key, - }); + const FilesSortActions({super.key}); @override Widget build(BuildContext context) { final theme = Theme.of(context); + final settings = context.read(); + final (currentSort, ascending) = context.select( + (SettingsCubit c) => + (c.state.fileSettings.sortBy, c.state.fileSettings.ascending), + ); return Row( mainAxisSize: MainAxisSize.min, children: [ @@ -46,7 +44,7 @@ class FilesSortActions extends StatelessWidget { ), ) .toList(), - onSelected: onDirectionChanged, + onSelected: (e) => settings.val(write: true).fileSettings.ascending = e, ), PopupMenuButton( tooltip: @@ -70,7 +68,7 @@ class FilesSortActions extends StatelessWidget { ), ) .toList(), - onSelected: onSortChanged, + onSelected: (e) => settings.val(write: true).fileSettings.sortBy = e, ), ], ); diff --git a/lib/view/pages/foreign_timetable/element_picker_page.dart b/lib/view/pages/foreign_timetable/element_picker_page.dart index d9ee475..2c10718 100644 --- a/lib/view/pages/foreign_timetable/element_picker_page.dart +++ b/lib/view/pages/foreign_timetable/element_picker_page.dart @@ -12,6 +12,7 @@ import '../../../state/app/modules/settings/bloc/settings_cubit.dart'; import '../../../storage/timetable_favorites_settings.dart'; import '../../../utils/haptics.dart'; import '../../../widget/app_progress_indicator.dart'; +import 'element_type_icon.dart'; /// Full-screen picker: choose an element type (or "Alle" to search across all /// types), filter the (potentially large) list client-side, and pick an element @@ -221,7 +222,7 @@ class _ElementPickerPageState extends State { return Padding( padding: const EdgeInsets.only(right: 8), child: ChoiceChip( - avatar: Icon(type == null ? Icons.apps : _iconFor(type), size: 18), + avatar: Icon(type == null ? Icons.apps : type.icon, size: 18), showCheckmark: false, label: Text(type?.label ?? 'Alle'), selected: _selectedType == type, @@ -293,7 +294,7 @@ class _ElementPickerPageState extends State { } } final item = filtered[index - headerCount]; - return _itemTile(item); + return _itemTile(item, favorites); }, ); }, @@ -316,7 +317,7 @@ class _ElementPickerPageState extends State { Widget _favoriteTile(FavoriteTimetableElement favorite) { return ListTile( - leading: Icon(_iconFor(favorite.type)), + leading: Icon(favorite.type.icon), title: Text(favorite.label), subtitle: Text(favorite.type.label), trailing: IconButton( @@ -329,12 +330,10 @@ class _ElementPickerPageState extends State { ); } - Widget _itemTile(_PickerItem item) { - final isFavorite = context - .watch() - .val() - .timetableFavoritesSettings - .isFavorite(item.type, item.id); + Widget _itemTile(_PickerItem item, List favorites) { + final isFavorite = favorites.any( + (f) => f.type == item.type && f.id == item.id, + ); // In the "Alle" tab the type is otherwise ambiguous, so surface it. final subtitleParts = [ @@ -344,7 +343,7 @@ class _ElementPickerPageState extends State { ]; return ListTile( - leading: Icon(_iconFor(item.type)), + leading: Icon(item.type.icon), title: Text(item.primary), subtitle: subtitleParts.isEmpty ? null : Text(subtitleParts.join(' · ')), trailing: IconButton( @@ -355,19 +354,6 @@ class _ElementPickerPageState extends State { onTap: () => _open(item), ); } - - static IconData _iconFor(TimetableElementType type) { - switch (type) { - case TimetableElementType.student: - return Icons.person_outline; - case TimetableElementType.teacher: - return Icons.school_outlined; - case TimetableElementType.room: - return Icons.meeting_room_outlined; - case TimetableElementType.schoolClass: - return Icons.groups_outlined; - } - } } class _PickerItem { diff --git a/lib/view/pages/foreign_timetable/element_type_icon.dart b/lib/view/pages/foreign_timetable/element_type_icon.dart new file mode 100644 index 0000000..d0d3d9d --- /dev/null +++ b/lib/view/pages/foreign_timetable/element_type_icon.dart @@ -0,0 +1,12 @@ +import 'package:flutter/material.dart'; + +import '../../../api/marianumconnect/queries/timetable_get_element_week/timetable_element_type.dart'; + +extension TimetableElementTypeIcon on TimetableElementType { + IconData get icon => switch (this) { + TimetableElementType.student => Icons.person_outline, + TimetableElementType.teacher => Icons.school_outlined, + TimetableElementType.room => Icons.meeting_room_outlined, + TimetableElementType.schoolClass => Icons.groups_outlined, + }; +} diff --git a/lib/view/pages/grade_averages/grade_averages_view.dart b/lib/view/pages/grade_averages/grade_averages_view.dart index 6386c23..deba8b5 100644 --- a/lib/view/pages/grade_averages/grade_averages_view.dart +++ b/lib/view/pages/grade_averages/grade_averages_view.dart @@ -15,14 +15,14 @@ class GradeAveragesView extends StatelessWidget { create: (context) => GradeAveragesBloc(), child: BlocBuilder( builder: (context, state) { - var bloc = context.watch(); + final bloc = context.read(); return Scaffold( appBar: AppBar( title: const Text('Notendurchschnittsrechner'), actions: [ Visibility( - visible: bloc.state.grades.isNotEmpty, + visible: state.grades.isNotEmpty, child: IconButton( tooltip: 'Alle zurücksetzen', onPressed: () => ConfirmDialog( @@ -59,7 +59,7 @@ class GradeAveragesView extends StatelessWidget { ) .toList(), onSelected: (isMiddleSchool) { - if (bloc.state.grades.isNotEmpty) { + if (state.grades.isNotEmpty) { ConfirmDialog( title: 'Notensystem wechseln', content: diff --git a/lib/view/pages/holidays/holidays_view.dart b/lib/view/pages/holidays/holidays_view.dart index 75ec1c9..47797d2 100644 --- a/lib/view/pages/holidays/holidays_view.dart +++ b/lib/view/pages/holidays/holidays_view.dart @@ -78,7 +78,7 @@ class HolidaysView extends StatelessWidget { }, child: (state, loading) { final holidays = bloc.getHolidays(); - if (holidays == null || holidays.isEmpty) { + if (holidays.isEmpty) { return const PlaceholderView( icon: Icons.beach_access_outlined, text: 'Keine Schulferien verfügbar', diff --git a/lib/view/pages/marianum_dates/marianum_dates_view.dart b/lib/view/pages/marianum_dates/marianum_dates_view.dart index 8aa2891..ecc0d65 100644 --- a/lib/view/pages/marianum_dates/marianum_dates_view.dart +++ b/lib/view/pages/marianum_dates/marianum_dates_view.dart @@ -71,20 +71,16 @@ class MarianumDatesView extends StatelessWidget { IconButton( tooltip: 'Suchen', icon: const Icon(Icons.search), - onPressed: () { - final events = bloc.getEvents() ?? const []; - showSearch( - context: context, - delegate: SearchMarianumDates(events), - ); - }, + onPressed: () => showSearch( + context: context, + delegate: SearchMarianumDates(bloc.getEvents()), + ), ), ], ), body: LoadableStateConsumer( child: (state, loading) { - final events = bloc.getEvents() ?? const []; - final groups = _groupByMonth(events); + final groups = _groupByMonth(bloc.getEvents()); if (groups.isEmpty) { return const PlaceholderView( diff --git a/lib/view/pages/overhang.dart b/lib/view/pages/overhang.dart index 349578f..d7dc9a0 100644 --- a/lib/view/pages/overhang.dart +++ b/lib/view/pages/overhang.dart @@ -17,6 +17,8 @@ class Overhang extends StatefulWidget { } class _OverhangState extends State { + late final Future _reviewAvailable = InAppReview.instance.isAvailable(); + @override Widget build(BuildContext context) => Scaffold( appBar: AppBar( @@ -48,7 +50,7 @@ class _OverhangState extends State { onTap: () => AppRoutes.openQrShare(context), ), FutureBuilder( - future: InAppReview.instance.isAvailable(), + future: _reviewAvailable, builder: (context, snapshot) { String? getPlatformStoreName() { if (Platform.isAndroid) return 'Play store'; @@ -60,19 +62,19 @@ class _OverhangState extends State { .openStoreListing(appStoreId: '6458789560') .then( (value) { - if (!context.mounted) return; - InfoDialog.show(context, 'Vielen Dank!'); - }, - onError: (error) { - if (!context.mounted) return; - InfoDialog.show( - context, - error.toString(), - copyable: true, - title: 'Fehler', + if (!context.mounted) return; + InfoDialog.show(context, 'Vielen Dank!'); + }, + onError: (error) { + if (!context.mounted) return; + InfoDialog.show( + context, + error.toString(), + copyable: true, + title: 'Fehler', + ); + }, ); - }, - ); final ready = snapshot.connectionState == ConnectionState.done; return ListTile( @@ -83,7 +85,7 @@ class _OverhangState extends State { (data) => Text('Im $data'), ), trailing: const Icon(Icons.arrow_right), - onTap: !ready ? null : rate + onTap: !ready ? null : rate, ); }, ), diff --git a/lib/view/pages/settings/chat_background_settings_page.dart b/lib/view/pages/settings/chat_background_settings_page.dart index 9bc47b0..cd09be4 100644 --- a/lib/view/pages/settings/chat_background_settings_page.dart +++ b/lib/view/pages/settings/chat_background_settings_page.dart @@ -9,6 +9,7 @@ import '../../../storage/chat_background_settings.dart'; import '../../../storage/settings.dart' as model; import '../../../utils/app_paths.dart'; import '../../../utils/haptics.dart'; +import '../../../utils/json_equality.dart'; import '../../../widget/async_action_button.dart'; import '../../../widget/chat_background.dart'; import '../../../widget/chat_background_picker_sheet.dart'; @@ -43,9 +44,10 @@ class ChatBackgroundSettingsPage extends StatelessWidget { builder: (context, _) { final settings = context.read(); final s = settings.val().chatBackgroundSettings; - final isModified = - s.toJson().toString() != - DefaultSettings.get().chatBackgroundSettings.toJson().toString(); + final isModified = !sameJson( + s, + DefaultSettings.get().chatBackgroundSettings, + ); final showImageControls = s.type == ChatBackgroundType.pattern || s.type == ChatBackgroundType.image; diff --git a/lib/view/pages/settings/modules_settings_page.dart b/lib/view/pages/settings/modules_settings_page.dart index 28081ca..9aabab5 100644 --- a/lib/view/pages/settings/modules_settings_page.dart +++ b/lib/view/pages/settings/modules_settings_page.dart @@ -5,6 +5,7 @@ import '../../../state/app/modules/app_modules.dart'; import '../../../state/app/modules/settings/bloc/settings_cubit.dart'; import '../../../storage/settings.dart' as model; import '../../../utils/haptics.dart'; +import '../../../utils/json_equality.dart'; import '../../../widget/confirm_dialog.dart'; import 'data/default_settings.dart'; @@ -147,9 +148,10 @@ class ModulesSettingsPage extends StatelessWidget { BlocBuilder( builder: (context, _) { final settings = context.read(); - final isModified = - settings.val().modulesSettings.toJson().toString() != - DefaultSettings.get().modulesSettings.toJson().toString(); + final isModified = !sameJson( + settings.val().modulesSettings, + DefaultSettings.get().modulesSettings, + ); return Scaffold( appBar: AppBar( title: const Text('Module'), diff --git a/lib/view/pages/settings/sections/about_section.dart b/lib/view/pages/settings/sections/about_section.dart index aed8218..4f93bee 100644 --- a/lib/view/pages/settings/sections/about_section.dart +++ b/lib/view/pages/settings/sections/about_section.dart @@ -50,10 +50,7 @@ class AboutSection extends StatelessWidget { _toggleDeveloperMode(context, settings, state), ), ), - Visibility( - visible: settings.val().devToolsEnabled, - child: DevToolsSection(settings: settings), - ), + if (settings.val().devToolsEnabled) DevToolsSection(settings: settings), ], ); } @@ -104,7 +101,9 @@ class AboutSection extends StatelessWidget { Icon(Icons.send_time_extension_outlined), ), title: const Text('Infos zu mhsl'), - subtitle: const Text('Für Push, Kalendertermine, Marianum Message und mehr'), + subtitle: const Text( + 'Für Push, Kalendertermine, Marianum Message und mehr', + ), trailing: const Icon(Icons.arrow_right), onTap: () => PrivacyInfo( providerText: 'mhsl', diff --git a/lib/view/pages/settings/sections/account_section.dart b/lib/view/pages/settings/sections/account_section.dart index 9857480..786d69c 100644 --- a/lib/view/pages/settings/sections/account_section.dart +++ b/lib/view/pages/settings/sections/account_section.dart @@ -22,21 +22,14 @@ import '../../../../widget/user_avatar.dart'; String? _cachedDisplayName; String? _cachedDisplayNameFor; -class AccountSection extends StatelessWidget { +class AccountSection extends StatefulWidget { const AccountSection({super.key}); @override - Widget build(BuildContext context) => const _SchoolAccount(); + State createState() => _AccountSectionState(); } -class _SchoolAccount extends StatefulWidget { - const _SchoolAccount(); - - @override - State<_SchoolAccount> createState() => _SchoolAccountState(); -} - -class _SchoolAccountState extends State<_SchoolAccount> { +class _AccountSectionState extends State { int _avatarVersion = 0; bool _avatarBusy = false; String? _displayName; diff --git a/lib/view/pages/settings/sections/appearance_section.dart b/lib/view/pages/settings/sections/appearance_section.dart index 85cb8c3..39d2b24 100644 --- a/lib/view/pages/settings/sections/appearance_section.dart +++ b/lib/view/pages/settings/sections/appearance_section.dart @@ -41,25 +41,15 @@ class AppearanceSection extends StatelessWidget { ); } - IconData _hapticIcon(HapticLevel level) { - switch (level) { - case HapticLevel.off: - return Icons.notifications_off_outlined; - case HapticLevel.reduced: - return Icons.notifications_paused_outlined; - case HapticLevel.full: - return Icons.notifications_active_outlined; - } - } + IconData _hapticIcon(HapticLevel level) => switch (level) { + HapticLevel.off => Icons.notifications_off_outlined, + HapticLevel.reduced => Icons.notifications_paused_outlined, + HapticLevel.full => Icons.notifications_active_outlined, + }; - String _hapticLabel(HapticLevel level) { - switch (level) { - case HapticLevel.off: - return 'Aus'; - case HapticLevel.reduced: - return 'Reduziert'; - case HapticLevel.full: - return 'Vollständig'; - } - } + String _hapticLabel(HapticLevel level) => switch (level) { + HapticLevel.off => 'Aus', + HapticLevel.reduced => 'Reduziert', + HapticLevel.full => 'Vollständig', + }; } diff --git a/lib/view/pages/settings/sections/dev_tools_section.dart b/lib/view/pages/settings/sections/dev_tools_section.dart index 0066644..162338c 100644 --- a/lib/view/pages/settings/sections/dev_tools_section.dart +++ b/lib/view/pages/settings/sections/dev_tools_section.dart @@ -26,15 +26,10 @@ import '../widgets/endpoint_picker.dart'; import '../widgets/settings_checkbox_tile.dart'; import '../widgets/settings_dropdown_tile.dart'; -class DevToolsSection extends StatefulWidget { +class DevToolsSection extends StatelessWidget { final SettingsCubit settings; const DevToolsSection({required this.settings, super.key}); - @override - State createState() => _DevToolsSectionState(); -} - -class _DevToolsSectionState extends State { @override Widget build(BuildContext context) => Column( children: [ @@ -54,11 +49,11 @@ class _DevToolsSectionState extends State { context, children: (sheetCtx) => [ BlocBuilder( - bloc: widget.settings, + bloc: settings, builder: (_, _) { - final dev = widget.settings.val().devToolsSettings; + final dev = settings.val().devToolsSettings; DevToolsSettings write() => - widget.settings.val(write: true).devToolsSettings; + settings.val(write: true).devToolsSettings; return Column( mainAxisSize: MainAxisSize.min, children: [ @@ -122,17 +117,17 @@ class _DevToolsSectionState extends State { title: const Text('Marianum-Connect-Server'), subtitle: Text( MarianumConnectEndpointPicker.labelFor( - widget.settings.val().devToolsSettings, + settings.val().devToolsSettings, ), ), trailing: const Icon(Icons.arrow_right), onTap: () => - MarianumConnectEndpointPicker.show(context, widget.settings), + MarianumConnectEndpointPicker.show(context, settings), ), BlocBuilder( - bloc: widget.settings, + bloc: settings, builder: (_, _) { - final override = widget.settings + final override = settings .val() .devToolsSettings .emergencyNoticeUrl @@ -143,7 +138,7 @@ class _DevToolsSectionState extends State { subtitle: Text(override.isEmpty ? 'Standardquelle' : override), trailing: const Icon(Icons.arrow_right), onTap: () => - _EmergencyNoticeUrlEditor.show(context, widget.settings), + _EmergencyNoticeUrlEditor.show(context, settings), ); }, ), @@ -152,18 +147,18 @@ class _DevToolsSectionState extends State { icon: Icons.settings_applications_outlined, title: 'Einstellungen', describe: () async => filesize( - utf8.encode(jsonEncode(widget.settings.val().toJson())).length, + utf8.encode(jsonEncode(settings.val().toJson())).length, ), onOpen: (_) async => AppRoutes.openJsonViewer( context, - widget.settings.val().toJson(), + settings.val().toJson(), title: 'Einstellungen', ), clearTitle: 'Einstellungen zurücksetzen', clearContent: 'Alle Einstellungen gehen verloren! Accountdaten sowie App-Daten ' 'sind nicht betroffen.', - onClear: widget.settings.reset, + onClear: settings.reset, ), _StorageTile( icon: Icons.account_tree_outlined, diff --git a/lib/view/pages/settings/widgets/privacy_info.dart b/lib/view/pages/settings/widgets/privacy_info.dart index 8e15c67..fc81125 100644 --- a/lib/view/pages/settings/widgets/privacy_info.dart +++ b/lib/view/pages/settings/widgets/privacy_info.dart @@ -5,11 +5,11 @@ import '../../../../widget/confirm_dialog.dart'; import '../../../../widget/details_bottom_sheet.dart'; class PrivacyInfo { - String providerText; - String privacyUrl; - String imprintUrl; + final String providerText; + final String privacyUrl; + final String imprintUrl; - PrivacyInfo({ + const PrivacyInfo({ required this.providerText, required this.imprintUrl, required this.privacyUrl, diff --git a/lib/view/pages/settings/widgets/push_status_sheet.dart b/lib/view/pages/settings/widgets/push_status_sheet.dart index 5f2ff76..989d1b7 100644 --- a/lib/view/pages/settings/widgets/push_status_sheet.dart +++ b/lib/view/pages/settings/widgets/push_status_sheet.dart @@ -228,11 +228,7 @@ class _PushStatusBodyState extends State<_PushStatusBody> /// otherwise re-registering IS the primary next step and the test action is /// omitted (it could not succeed and the checklist explains why). Widget _actions(PushStatusReport report) { - const spinner = SizedBox( - width: 16, - height: 16, - child: CircularProgressIndicator(strokeWidth: 2), - ); + const spinner = AppProgressIndicator.small(); final ready = report.readyForTestNotification; return Row( crossAxisAlignment: CrossAxisAlignment.start, diff --git a/lib/view/pages/share_intent/share_chat_picker.dart b/lib/view/pages/share_intent/share_chat_picker.dart index 10882a0..73d046d 100644 --- a/lib/view/pages/share_intent/share_chat_picker.dart +++ b/lib/view/pages/share_intent/share_chat_picker.dart @@ -67,7 +67,12 @@ class ShareChatPicker extends StatelessWidget { @override Widget build(BuildContext context) { - final talkSettings = context.watch().val().talkSettings; + final (favoritesToTop, unreadToTop) = context.select( + (SettingsCubit c) => ( + c.state.talkSettings.sortFavoritesToTop, + c.state.talkSettings.sortUnreadToTop, + ), + ); return Scaffold( appBar: AppBar( title: const Text('Talk-Chat auswählen'), @@ -100,8 +105,8 @@ class ShareChatPicker extends StatelessWidget { if (rooms == null) return const SizedBox.shrink(); final sorted = rooms .sortBy( - favoritesToTop: talkSettings.sortFavoritesToTop, - unreadToTop: talkSettings.sortUnreadToTop, + favoritesToTop: favoritesToTop, + unreadToTop: unreadToTop, ) // Hide chats the user can't write to (announcement channels, // archived rooms, …) — uploading there would only fail at the @@ -195,8 +200,8 @@ Future _runShareFlow( try { await action(); } catch (e) { - if (context.mounted) Navigator.of(context).pop(); if (context.mounted) { + Navigator.of(context).pop(); InfoDialog.show( context, errorToUserMessage(e), diff --git a/lib/view/pages/share_intent/share_folder_picker.dart b/lib/view/pages/share_intent/share_folder_picker.dart index e6f5fc9..4859af0 100644 --- a/lib/view/pages/share_intent/share_folder_picker.dart +++ b/lib/view/pages/share_intent/share_folder_picker.dart @@ -13,11 +13,10 @@ import '../../../state/app/infrastructure/loadable_state/view/loadable_state_con import '../../../state/app/infrastructure/utility_widgets/bloc_module.dart'; import '../../../state/app/modules/files/bloc/files_bloc.dart'; import '../../../state/app/modules/files/bloc/files_state.dart'; -import '../../../state/app/modules/settings/bloc/settings_cubit.dart'; import '../../../widget/centered_leading.dart'; import '../../../widget/info_dialog.dart'; import '../../../widget/placeholder_view.dart'; -import '../files/data/sort_options.dart'; +import '../files/data/file_sort.dart'; import '../files/files_upload_dialog.dart'; import '../files/widgets/add_file_menu.dart'; import '../files/widgets/file_leading.dart'; @@ -73,22 +72,6 @@ class _ShareFolderPickerView extends StatefulWidget { } class _ShareFolderPickerViewState extends State<_ShareFolderPickerView> { - late final SettingsCubit _settings; - late SortOption _currentSort; - late bool _ascending; - - @override - void initState() { - super.initState(); - _settings = context.read(); - _currentSort = _settings.val().fileSettings.sortBy; - _ascending = _settings.val().fileSettings.ascending; - } - - void _enter(FilesBloc bloc, List currentPath, String folderName) { - bloc.setPath([...currentPath, folderName]); - } - void _goUp(FilesBloc bloc, List currentPath) { if (currentPath.isEmpty) return; bloc.setPath(currentPath.sublist(0, currentPath.length - 1)); @@ -97,6 +80,9 @@ class _ShareFolderPickerViewState extends State<_ShareFolderPickerView> { @override Widget build(BuildContext context) { final bloc = context.read(); + // Selected here, not in the LoadableStateConsumer child: that closure runs + // during the consumer's build, where this context may not subscribe. + final sort = context.select(fileSortOf); return BlocBuilder>( buildWhen: (a, b) => a.data?.currentPath != b.data?.currentPath, builder: (_, outerState) { @@ -109,9 +95,9 @@ class _ShareFolderPickerViewState extends State<_ShareFolderPickerView> { canPop: currentPath.isEmpty, onPopInvokedWithResult: (didPop, _) { if (didPop) return; - if (currentPath.isNotEmpty) _goUp(bloc, currentPath); + _goUp(bloc, currentPath); }, - child: _buildScaffold(context, bloc, currentPath), + child: _buildScaffold(context, bloc, currentPath, sort), ); }, ); @@ -121,6 +107,7 @@ class _ShareFolderPickerViewState extends State<_ShareFolderPickerView> { BuildContext context, FilesBloc bloc, List currentPath, + FileSort sort, ) => Scaffold( appBar: AppBar( title: Text( @@ -133,22 +120,7 @@ class _ShareFolderPickerViewState extends State<_ShareFolderPickerView> { tooltip: 'Ordner erstellen', onPressed: () => showCreateFolderDialog(context, bloc), ), - FilesSortActions( - currentSort: _currentSort, - ascending: _ascending, - onDirectionChanged: (e) { - setState(() { - _ascending = e; - _settings.val(write: true).fileSettings.ascending = e; - }); - }, - onSortChanged: (e) { - setState(() { - _currentSort = e; - _settings.val(write: true).fileSettings.sortBy = e; - }); - }, - ), + const FilesSortActions(), ], ), floatingActionButton: FloatingActionButton.extended( @@ -160,12 +132,7 @@ class _ShareFolderPickerViewState extends State<_ShareFolderPickerView> { body: LoadableStateConsumer( isReady: (state) => state.listing != null, child: (state, _) { - final listing = state.listing!; - final entries = listing.sortBy( - sortOption: _currentSort, - foldersToTop: _settings.val().fileSettings.sortFoldersToTop, - reversed: _ascending, - ); + final entries = state.listing!.sortedBy(sort); if (entries.isEmpty) { return PlaceholderView( @@ -181,18 +148,16 @@ class _ShareFolderPickerViewState extends State<_ShareFolderPickerView> { itemCount: entries.length, itemBuilder: (context, i) { final entry = entries[i]; - if (entry.isDirectory) { - return ListTile( - leading: CenteredLeading(FileLeading(file: entry)), - title: Text(entry.name), - trailing: const Icon(Icons.chevron_right), - onTap: () => _enter(bloc, state.currentPath, entry.name), - ); - } return ListTile( - enabled: false, + enabled: entry.isDirectory, leading: CenteredLeading(FileLeading(file: entry)), title: Text(entry.name), + trailing: entry.isDirectory + ? const Icon(Icons.chevron_right) + : null, + onTap: entry.isDirectory + ? () => bloc.setPath([...state.currentPath, entry.name]) + : null, ); }, ); diff --git a/lib/view/pages/talk/chat_view.dart b/lib/view/pages/talk/chat_view.dart index 6005875..9964e67 100644 --- a/lib/view/pages/talk/chat_view.dart +++ b/lib/view/pages/talk/chat_view.dart @@ -61,15 +61,15 @@ class _ChatViewState extends State with RouteAware { // Captured in initState because the framework has unmounted us by the // time dispose runs. - ChatBloc? _chatBlocRef; - ChatListBloc? _chatListBlocRef; + late final ChatBloc _chatBloc; + late final ChatListBloc _chatListBloc; PageRoute? _subscribedRoute; @override void initState() { super.initState(); - _chatBlocRef = context.read(); - _chatListBlocRef = context.read(); + _chatBloc = context.read(); + _chatListBloc = context.read(); _positionsListener.itemPositions.addListener(_onScrollPositions); NotificationTasks.clearNotificationsForChat(widget.room.token); } @@ -83,9 +83,7 @@ class _ChatViewState extends State with RouteAware { // before the user reaches the top — the load stays invisible. final maxIndex = positions.map((p) => p.index).reduce(math.max); if (maxIndex < _itemCount - _kLoadOlderPrefetchRows) return; - final data = _chatBlocRef?.state.data; - if (data == null || !data.hasMoreOld || data.isLoadingOlder) return; - _chatBlocRef?.loadOlder(); + _chatBloc.loadOlder(); } @override @@ -107,7 +105,7 @@ class _ChatViewState extends State with RouteAware { // A stacked chat above us was just popped (typical: notification tap // opened another chat). The global ChatBloc currently points at that // other chat's token, so our isReady predicate fails until we re-claim. - _chatBlocRef?.setToken(widget.room.token); + _chatBloc.setToken(widget.room.token); } @override @@ -117,7 +115,7 @@ class _ChatViewState extends State with RouteAware { } _positionsListener.itemPositions.removeListener(_onScrollPositions); _markAsReadFinal(); - _chatBlocRef?.leaveChat(widget.room.token); + _chatBloc.leaveChat(widget.room.token); _searchTextController.dispose(); Debouncer.cancel(_searchDebounceTag); super.dispose(); @@ -129,15 +127,15 @@ class _ChatViewState extends State with RouteAware { /// belongs to a different room, and writing its max-id as our marker /// would regress our server cursor. void _markAsReadFinal() { - final state = _chatBlocRef?.state.data; + final state = _chatBloc.state.data; if (state == null) return; if (state.currentToken != widget.room.token) return; final response = state.chatResponse; if (response == null) return; final maxId = response.data.map((m) => m.id).fold(0, math.max); if (maxId == 0) return; - _chatListBlocRef?.markRoomAsRead(widget.room.token, maxId); - unawaited(_chatBlocRef!.sendServerReadMarker(widget.room.token, maxId)); + _chatListBloc.markRoomAsRead(widget.room.token, maxId); + unawaited(_chatBloc.sendServerReadMarker(widget.room.token, maxId)); } @override @@ -148,31 +146,34 @@ class _ChatViewState extends State with RouteAware { } } - void _refresh() { - context.read().setToken(widget.room.token); - } - - void _refetch({bool renew = false}) => _refresh(); + void _refetch() => _chatBloc.setToken(widget.room.token); // The built rows only depend on the chat data and the search state; the list // itself rebuilds far more often (loading flips, keyboard, parent rebuilds), // and re-sorting plus re-creating every bubble each time is wasted work. + // The top marker is cached separately so a loading-older flip doesn't + // rebuild every message row. + Object? _messagesKey; + List _messages = const []; Object? _itemsKey; List _items = const []; List _itemsFor(ChatState state) { - final key = ( + final messagesKey = ( state.chatResponse, - state.isLoadingOlder, - state.hasMoreOld, _searchActive, _searchQuery, _activeMatchIndex, widget.room, ); + if (messagesKey != _messagesKey) { + _messagesKey = messagesKey; + _messages = _buildMessages(state.chatResponse!).reversed.toList(); + } + final key = (_messages, state.isLoadingOlder, state.hasMoreOld); if (key == _itemsKey) return _items; _itemsKey = key; - final items = _buildMessages(state.chatResponse!).reversed.toList(); + final items = [..._messages]; // reverse:true renders index 0 at the bottom, so the top marker // (spinner / start-of-chat) goes at the end. if (state.isLoadingOlder) { @@ -191,26 +192,20 @@ class _ChatViewState extends State with RouteAware { return _items = items; } - void _enterSearchMode() { - setState(() { - _searchActive = true; - _searchQuery = ''; - _matches = const []; - _activeMatchIndex = 0; - _matchesComputedFor = null; - _matchesComputedQuery = null; - _searchTextController.clear(); - }); - } + void _enterSearchMode() => _resetSearch(active: true); void _exitSearchMode() { Debouncer.cancel(_searchDebounceTag); + _matchIndices.clear(); + _resetSearch(active: false); + } + + void _resetSearch({required bool active}) { setState(() { - _searchActive = false; + _searchActive = active; _searchQuery = ''; _matches = const []; _activeMatchIndex = 0; - _matchIndices.clear(); _matchesComputedFor = null; _matchesComputedQuery = null; _searchTextController.clear(); @@ -233,7 +228,7 @@ class _ChatViewState extends State with RouteAware { } void _applySearch(String q) { - final chatResponse = context.read().state.data?.chatResponse; + final chatResponse = _chatBloc.state.data?.chatResponse; setState(() { _searchQuery = q; _activeMatchIndex = 0; @@ -260,19 +255,12 @@ class _ChatViewState extends State with RouteAware { _matchesComputedQuery = _searchQuery; } - void _goToPreviousMatch() { - if (_matches.isEmpty) return; - setState(() { - _activeMatchIndex = (_activeMatchIndex + 1) % _matches.length; - }); - WidgetsBinding.instance.addPostFrameCallback((_) => _scrollToActiveMatch()); - } - - void _goToNextMatch() { + // Matches are newest-first, so "previous" (older) steps forward. + void _stepMatch(int delta) { if (_matches.isEmpty) return; setState(() { _activeMatchIndex = - (_activeMatchIndex - 1 + _matches.length) % _matches.length; + (_activeMatchIndex + delta + _matches.length) % _matches.length; }); WidgetsBinding.instance.addPostFrameCallback((_) => _scrollToActiveMatch()); } @@ -379,6 +367,11 @@ class _ChatViewState extends State with RouteAware { @override Widget build(BuildContext context) { + final input = ChatTextfield( + widget.room.token, + selfId: widget.selfId, + roomType: widget.room.type, + ); return _KeyboardDismissPopScope( child: Scaffold( backgroundColor: const Color(0xffefeae2), @@ -389,8 +382,8 @@ class _ChatViewState extends State with RouteAware { activeIndex: _matches.isEmpty ? -1 : _activeMatchIndex, onChanged: _onSearchChanged, onClose: _exitSearchMode, - onPrevious: _matches.isEmpty ? null : _goToPreviousMatch, - onNext: _matches.isEmpty ? null : _goToNextMatch, + onPrevious: _matches.isEmpty ? null : () => _stepMatch(1), + onNext: _matches.isEmpty ? null : () => _stepMatch(-1), ) : ClickableAppBar( onTap: () => @@ -443,18 +436,8 @@ class _ChatViewState extends State with RouteAware { ColoredBox( color: Theme.of(context).colorScheme.surface, child: TalkNavigator.isSecondaryVisible(context) - ? ChatTextfield( - widget.room.token, - selfId: widget.selfId, - roomType: widget.room.type, - ) - : SafeArea( - child: ChatTextfield( - widget.room.token, - selfId: widget.selfId, - roomType: widget.room.type, - ), - ), + ? input + : SafeArea(child: input), ), ], ), diff --git a/lib/view/pages/talk/data/chat_bubble_styles.dart b/lib/view/pages/talk/data/chat_bubble_styles.dart index c91086b..88299e8 100644 --- a/lib/view/pages/talk/data/chat_bubble_styles.dart +++ b/lib/view/pages/talk/data/chat_bubble_styles.dart @@ -20,7 +20,7 @@ class ChatBubbleStyles { ? const Color(0xff182229) : Colors.white, elevation: 2, - margin: const BubbleEdges.only(bottom: 20, top: 10), + margin: const EdgeInsets.only(bottom: 20, top: 10), alignment: Alignment.center, ); @@ -30,7 +30,7 @@ class ChatBubbleStyles { ? const Color(0xff202c33) : Colors.white, elevation: 1, - margin: const BubbleEdges.only(bottom: 10, left: 10, right: 50), + margin: const EdgeInsets.only(bottom: 10, left: 10, right: 50), alignment: Alignment.topLeft, ); @@ -40,7 +40,7 @@ class ChatBubbleStyles { ? const Color(0xff005c4b) : const Color(0xffd3d3d3), elevation: 1, - margin: const BubbleEdges.only(bottom: 10, right: 10, left: 50), + margin: const EdgeInsets.only(bottom: 10, right: 10, left: 50), alignment: Alignment.topRight, ); } diff --git a/lib/view/pages/talk/data/chat_message.dart b/lib/view/pages/talk/data/chat_message.dart index ae69133..2784ac6 100644 --- a/lib/view/pages/talk/data/chat_message.dart +++ b/lib/view/pages/talk/data/chat_message.dart @@ -11,27 +11,33 @@ import '../widgets/highlighted_linkify.dart'; import '../widgets/message_markdown.dart'; class ChatMessage { - String originalMessage; - Map? originalData; + final String originalMessage; + final Map? originalData; - RichObjectString? file; - String content = ''; + final RichObjectString? file; + final String content; - ChatMessage({required this.originalMessage, this.originalData}) { - if (originalData?.containsKey('file') ?? false) { - file = originalData?['file']; - } - content = RichObjectStringProcessor.parseToString( - originalMessage, - originalData, - ); - } + ChatMessage({required this.originalMessage, this.originalData}) + : file = originalData?['file'], + content = RichObjectStringProcessor.parseToString( + originalMessage, + originalData, + ); Widget getWidget({ String? highlightQuery, TextStyle? style, bool renderMarkdown = false, }) { + final poll = originalData?['object']; + if (poll?.type == RichObjectStringObjectType.talkPoll) { + return ListTile( + leading: const Icon(Icons.poll_outlined), + title: Text(poll!.name), + contentPadding: const EdgeInsets.only(left: 10), + ); + } + final emojiFontSize = standaloneEmojiFontSize(content); final effectiveStyle = emojiFontSize == null ? style @@ -64,14 +70,7 @@ class ChatMessage { ); } - if (originalData?['object']?.type == RichObjectStringObjectType.talkPoll) { - return ListTile( - leading: const Icon(Icons.poll_outlined), - title: Text(originalData!['object']!.name), - contentPadding: const EdgeInsets.only(left: 10), - ); - } - + final file = this.file; if (file == null) return contentWidget; return Padding( @@ -88,7 +87,7 @@ class ChatMessage { const SizedBox(width: 10), Flexible( child: Text( - file!.name, + file.name, maxLines: 2, overflow: TextOverflow.ellipsis, style: const TextStyle(fontWeight: FontWeight.bold), @@ -107,7 +106,7 @@ class ChatMessage { errorListener: (value) {}, httpHeaders: SessionManager().requireNextcloud().authHeaders, imageUrl: - 'https://${EndpointData().nextcloud().full()}/index.php/core/preview?fileId=${file!.id}&x=130&y=-1&a=1', + 'https://${EndpointData().nextcloud().full()}/index.php/core/preview?fileId=${file.id}&x=130&y=-1&a=1', ), if (originalMessage != '{file}') ...[ SizedBox(height: 5), diff --git a/lib/view/pages/talk/data/talk_markdown.dart b/lib/view/pages/talk/data/talk_markdown.dart index 094082b..1078fbb 100644 --- a/lib/view/pages/talk/data/talk_markdown.dart +++ b/lib/view/pages/talk/data/talk_markdown.dart @@ -53,9 +53,11 @@ String markdownToPlainText(String source) { final buffer = StringBuffer(); _collect(nodes, buffer); - return buffer.toString().replaceAll(RegExp(r'\s+'), ' ').trim(); + return buffer.toString().replaceAll(_whitespace, ' ').trim(); } +final _whitespace = RegExp(r'\s+'); + void _collect(List nodes, StringBuffer buffer) { for (final node in nodes) { if (node is md.Text) { diff --git a/lib/view/pages/talk/details/chat_info.dart b/lib/view/pages/talk/details/chat_info.dart index 5f07346..5df6407 100644 --- a/lib/view/pages/talk/details/chat_info.dart +++ b/lib/view/pages/talk/details/chat_info.dart @@ -16,6 +16,7 @@ import '../../../../widget/large_profile_picture_view.dart'; import '../../../../widget/user_avatar.dart'; import '../talk_navigator.dart'; import '../widgets/notification_level_sheet.dart'; +import '../widgets/room_actions.dart'; import 'participants_list_view.dart'; import 'shared_items_view.dart'; @@ -82,19 +83,8 @@ class _ChatInfoState extends State { } Future _confirmLeave() async { - final closed = await showDialog( - context: context, - builder: (_) => ConfirmDialog( - title: 'Talk-Chat verlassen', - content: 'Du benötigst ggf. eine Einladung um erneut beizutreten.', - confirmButton: 'Verlassen', - onConfirmAsync: () async { - await LeaveRoom(widget.room.token).run(); - if (mounted) _refreshList(); - }, - ), - ); - if (closed == true && mounted) Navigator.of(context).pop(); + final left = await confirmLeaveRoom(context, widget.room.token); + if (left && mounted) Navigator.of(context).pop(); } // Spreed's POST /room/{token}/avatar requires moderator rights and rejects @@ -245,18 +235,10 @@ class _ChatInfoState extends State { SharedItemsView(widget.room, overview: _sharesOverview), ), ), - if (_isFavorite) - AsyncListTile( - leading: const Icon(Icons.stars_outlined), - title: const Text('Von Favoriten entfernen'), - onPressed: _toggleFavorite, - ) - else - AsyncListTile( - leading: const Icon(Icons.star_outline), - title: const Text('Zu Favoriten hinzufügen'), - onPressed: _toggleFavorite, - ), + FavoriteToggleTile( + isFavorite: _isFavorite, + onPressed: _toggleFavorite, + ), NotificationLevelTile( level: _notificationLevel, trailing: const Icon(Icons.arrow_right), diff --git a/lib/view/pages/talk/details/participants_list_view.dart b/lib/view/pages/talk/details/participants_list_view.dart index 25324df..f0a7732 100644 --- a/lib/view/pages/talk/details/participants_list_view.dart +++ b/lib/view/pages/talk/details/participants_list_view.dart @@ -22,8 +22,8 @@ class ParticipantsListView extends StatelessWidget { @override Widget build(BuildContext context) { - String lastname(participant) => - participant.displayName.toString().split(' ').last; + String lastname(GetParticipantsResponseObject participant) => + participant.displayName.split(' ').last; final participants = participantsResponse.data.sorted((a, b) { final typeComparison = a.participantType.index.compareTo( diff --git a/lib/view/pages/talk/details/shared_items_view.dart b/lib/view/pages/talk/details/shared_items_view.dart index f288b4a..6f53b76 100644 --- a/lib/view/pages/talk/details/shared_items_view.dart +++ b/lib/view/pages/talk/details/shared_items_view.dart @@ -23,19 +23,18 @@ const int _sharedItemsPageSize = 20; /// handled separately (split into Bilder/Videos). `location`/`deckcard` are rich /// objects without a downloadable file, so they have no place in this view. const List<_SharedCategory> _sharedCategories = [ - _SharedCategory('file', 'Dokumente', _SharedItemsLayout.list), - _SharedCategory('voice', 'Sprachnachrichten', _SharedItemsLayout.list), - _SharedCategory('audio', 'Audio', _SharedItemsLayout.list), - _SharedCategory('recording', 'Aufnahmen', _SharedItemsLayout.list), - _SharedCategory('other', 'Sonstiges', _SharedItemsLayout.list), + _SharedCategory('file', 'Dokumente'), + _SharedCategory('voice', 'Sprachnachrichten'), + _SharedCategory('audio', 'Audio'), + _SharedCategory('recording', 'Aufnahmen'), + _SharedCategory('other', 'Sonstiges'), ]; class _SharedCategory { final String objectType; final String label; - final _SharedItemsLayout layout; - const _SharedCategory(this.objectType, this.label, this.layout); + const _SharedCategory(this.objectType, this.label); } bool _isVideoItem(GetChatResponseObject item) { @@ -203,7 +202,7 @@ class _SharedItemsViewState extends State _SharedItemsTab( token: widget.room.token, objectType: category.objectType, - layout: category.layout, + layout: _SharedItemsLayout.list, emptyLabel: 'Keine Einträge', initialPage: _seedFromOverview(raw), ), diff --git a/lib/view/pages/talk/search_chat.dart b/lib/view/pages/talk/search_chat.dart index 919e0a7..da66ade 100644 --- a/lib/view/pages/talk/search_chat.dart +++ b/lib/view/pages/talk/search_chat.dart @@ -11,7 +11,7 @@ import 'widgets/chat_tile.dart'; import 'widgets/user_search_tile.dart'; class SearchChat extends SearchDelegate { - List chats; + final List chats; final void Function(GetRoomResponseObject room)? onTapOverride; /// When set, results are extended with a clearly separated "start a new chat" @@ -145,8 +145,8 @@ class _SearchChatResultsState extends State<_SearchChatResults> { widget.chats .where( (e) => - e.displayName.toString().toLowerCase().contains(query) || - e.name.toString().toLowerCase().contains(query), + e.displayName.toLowerCase().contains(query) || + e.name.toLowerCase().contains(query), ) .toList() ..sort((a, b) => b.lastActivity.compareTo(a.lastActivity)); diff --git a/lib/view/pages/talk/widgets/bubble.dart b/lib/view/pages/talk/widgets/bubble.dart index 436fa05..ceeab73 100644 --- a/lib/view/pages/talk/widgets/bubble.dart +++ b/lib/view/pages/talk/widgets/bubble.dart @@ -2,35 +2,14 @@ import 'package:flutter/material.dart'; enum BubbleNip { leftTop, rightBottom, none } -class BubbleEdges { - const BubbleEdges.only({ - this.top = 0, - this.bottom = 0, - this.left = 0, - this.right = 0, - }); - const BubbleEdges.all(double value) - : top = value, - bottom = value, - left = value, - right = value; - - final double top; - final double bottom; - final double left; - final double right; - - EdgeInsets toEdgeInsets() => EdgeInsets.fromLTRB(left, top, right, bottom); -} - class BubbleStyle { const BubbleStyle({ this.color, this.borderWidth = 0, this.borderColor, this.elevation = 0, - this.margin = const BubbleEdges.only(), - this.padding = const BubbleEdges.all(8), + this.margin = EdgeInsets.zero, + this.padding = const EdgeInsets.all(8), this.alignment = Alignment.centerLeft, this.nip = BubbleNip.none, this.borderRadius = 12, @@ -40,23 +19,24 @@ class BubbleStyle { final double borderWidth; final Color? borderColor; final double elevation; - final BubbleEdges margin; - final BubbleEdges padding; + final EdgeInsets margin; + final EdgeInsets padding; final Alignment alignment; final BubbleNip nip; final double borderRadius; - BubbleStyle copyWith({double? borderWidth, Color? borderColor}) => BubbleStyle( - color: color, - borderWidth: borderWidth ?? this.borderWidth, - borderColor: borderColor ?? this.borderColor, - elevation: elevation, - margin: margin, - padding: padding, - alignment: alignment, - nip: nip, - borderRadius: borderRadius, - ); + BubbleStyle copyWith({double? borderWidth, Color? borderColor}) => + BubbleStyle( + color: color, + borderWidth: borderWidth ?? this.borderWidth, + borderColor: borderColor ?? this.borderColor, + elevation: elevation, + margin: margin, + padding: padding, + alignment: alignment, + nip: nip, + borderRadius: borderRadius, + ); } /// The "nip" is faked by flattening one corner so the bubble anchors to @@ -96,7 +76,7 @@ class Bubble extends StatelessWidget { return Align( alignment: style.alignment, child: Container( - margin: style.margin.toEdgeInsets(), + margin: style.margin, decoration: BoxDecoration( color: style.color, borderRadius: radius, @@ -116,7 +96,7 @@ class Bubble extends StatelessWidget { ] : null, ), - padding: style.padding.toEdgeInsets(), + padding: style.padding, child: child, ), ); diff --git a/lib/view/pages/talk/widgets/chat_bubble.dart b/lib/view/pages/talk/widgets/chat_bubble.dart index cb58ecf..027369c 100644 --- a/lib/view/pages/talk/widgets/chat_bubble.dart +++ b/lib/view/pages/talk/widgets/chat_bubble.dart @@ -23,6 +23,10 @@ import 'highlighted_linkify.dart'; enum SearchHighlight { none, secondary, active } +const double _kSpacing = 3; +const double _kTimeIconSize = 11; +const Color _kTimeIconColor = Colors.grey; + class ChatBubble extends StatefulWidget { final bool isSender; final GetChatResponseObject bubbleData; @@ -30,11 +34,7 @@ class ChatBubble extends StatefulWidget { final bool isRead; final String? selfId; - final double spacing = 3; - final double timeIconSize = 11; - final Color timeIconColor = Colors.grey; - - final void Function({bool renew}) refetch; + final VoidCallback refetch; final String? highlightQuery; final SearchHighlight matchHighlight; @@ -66,7 +66,9 @@ class _ChatBubbleState extends State Object? _messageKey; late Widget _messageWidget; - Offset _position = Offset.zero; + // Only the translate offset changes per swipe frame; a notifier keeps those + // frames from rebuilding the whole bubble. + final ValueNotifier _position = ValueNotifier(Offset.zero); Offset _dragStartPosition = Offset.zero; bool _swipeActionArmed = false; @@ -90,6 +92,7 @@ class _ChatBubbleState extends State @override void dispose() { disposeDownloadTrigger(); + _position.dispose(); super.dispose(); } @@ -165,19 +168,20 @@ class _ChatBubbleState extends State /// `TapGestureRecognizer` does not enter the gesture arena — otherwise /// it competes with (and blocks) the per-link `TapGestureRecognizer`s /// that `HighlightedLinkify` / the Markdown renderer attach to link spans. - bool get _hasTapAction { + bool get _hasTapAction => _poll != null || message.file != null; + + RichObjectString? get _poll { final obj = message.originalData?['object']; - if (obj?.type == RichObjectStringObjectType.talkPoll) return true; - return message.file != null; + return obj?.type == RichObjectStringObjectType.talkPoll ? obj : null; } void _onTap() { - final obj = message.originalData?['object']; - if (obj?.type == RichObjectStringObjectType.talkPoll) { + final obj = _poll; + if (obj != null) { showChatBubblePollSheet( context, room: widget.chatData, - pollId: int.parse(obj!.id), + pollId: int.parse(obj.id), pollName: obj.name, refetch: widget.refetch, ); @@ -257,10 +261,7 @@ class _ChatBubbleState extends State widget.bubbleData.timestamp * 1000, ).formatHm(), textAlign: TextAlign.end, - style: TextStyle( - color: widget.timeIconColor, - fontSize: widget.timeIconSize, - ), + style: const TextStyle(color: _kTimeIconColor, fontSize: _kTimeIconSize), ); return Column( @@ -270,24 +271,23 @@ class _ChatBubbleState extends State crossAxisAlignment: CrossAxisAlignment.end, children: [ GestureDetector( - onHorizontalDragStart: (_) => _dragStartPosition = _position, + onHorizontalDragStart: (_) => _dragStartPosition = _position.value, onHorizontalDragUpdate: (details) { if (!widget.bubbleData.isReplyable) return; final dx = details.delta.dx - _dragStartPosition.dx; - setState(() { - _position = (_position.dx + dx).abs() > 60 - ? Offset(_position.dx, 0) - : Offset(_position.dx + dx, 0); - }); + final current = _position.value.dx; + _position.value = (current + dx).abs() > 60 + ? Offset(current, 0) + : Offset(current + dx, 0); // Beim Überqueren der Action-Schwelle einmalig Haptik feuern, // damit der User physisch spürt: "jetzt löst's beim Loslassen aus". - final isArmed = _position.dx.abs() > 50; + final isArmed = _position.value.dx.abs() > 50; if (isArmed && !_swipeActionArmed) Haptics.longPress(); _swipeActionArmed = isArmed; }, onHorizontalDragEnd: (_) { - final isAction = _position.dx.abs() > 50; - setState(() => _position = Offset.zero); + final isAction = _position.value.dx.abs() > 50; + _position.value = Offset.zero; _swipeActionArmed = false; if (widget.bubbleData.isReplyable && isAction) { context.read().setReferenceMessageId( @@ -298,8 +298,10 @@ class _ChatBubbleState extends State onLongPress: _showOptionsDialog, onDoubleTap: _showOptionsDialog, onTap: _hasTapAction ? _onTap : null, - child: Transform.translate( - offset: _position, + child: ValueListenableBuilder( + valueListenable: _position, + builder: (context, offset, child) => + Transform.translate(offset: offset, child: child), child: Bubble( style: _getStyle(), child: _BubbleContent( @@ -312,9 +314,6 @@ class _ChatBubbleState extends State isSender: widget.isSender, isRead: widget.isRead, selfId: widget.selfId, - spacing: widget.spacing, - timeIconSize: widget.timeIconSize, - timeIconColor: widget.timeIconColor, showActorDisplayName: showActorDisplayName, showBubbleTime: showBubbleTime, downloadJob: downloadJob, @@ -343,9 +342,6 @@ class _BubbleContent extends StatelessWidget { final bool isSender; final bool isRead; final String? selfId; - final double spacing; - final double timeIconSize; - final Color timeIconColor; final bool showActorDisplayName; final bool showBubbleTime; final DownloadJob? downloadJob; @@ -360,9 +356,6 @@ class _BubbleContent extends StatelessWidget { required this.isSender, required this.isRead, required this.selfId, - required this.spacing, - required this.timeIconSize, - required this.timeIconColor, required this.showActorDisplayName, required this.showBubbleTime, required this.downloadJob, @@ -376,7 +369,7 @@ class _BubbleContent extends StatelessWidget { minWidth: showActorDisplayName ? actorText.measuredWidth(context) : timeText.measuredWidth(context) + - (isSender ? spacing + timeIconSize : 0) + + (isSender ? _kSpacing + _kTimeIconSize : 0) + 3, ), child: Stack( @@ -409,11 +402,11 @@ class _BubbleContent extends StatelessWidget { children: [ timeText, if (isSender) ...[ - SizedBox(width: spacing), + const SizedBox(width: _kSpacing), Icon( isRead ? Icons.done_all_outlined : Icons.done_outlined, - size: timeIconSize, - color: timeIconColor, + size: _kTimeIconSize, + color: _kTimeIconColor, semanticLabel: isRead ? A11yLabels.messageRead : A11yLabels.messageSent, diff --git a/lib/view/pages/talk/widgets/chat_bubble_poll.dart b/lib/view/pages/talk/widgets/chat_bubble_poll.dart index 92512d6..5f10be2 100644 --- a/lib/view/pages/talk/widgets/chat_bubble_poll.dart +++ b/lib/view/pages/talk/widgets/chat_bubble_poll.dart @@ -16,7 +16,7 @@ void showChatBubblePollSheet( required GetRoomResponseObject room, required int pollId, required String pollName, - required void Function({bool renew}) refetch, + required VoidCallback refetch, }) { showDetailsBottomSheet( context, @@ -36,7 +36,7 @@ void showChatBubblePollSheet( class _PollSheetBody extends StatefulWidget { final GetRoomResponseObject room; final int pollId; - final void Function({bool renew}) refetch; + final VoidCallback refetch; const _PollSheetBody({ required this.room, @@ -49,21 +49,13 @@ class _PollSheetBody extends StatefulWidget { } class _PollSheetBodyState extends State<_PollSheetBody> { - Future? _future; + late Future _future = _fetch(); - @override - void initState() { - super.initState(); - _future = GetPollState(token: widget.room.token, pollId: widget.pollId).run(); - } + Future _fetch() => + GetPollState(token: widget.room.token, pollId: widget.pollId).run(); void _reload() { - setState(() { - _future = GetPollState( - token: widget.room.token, - pollId: widget.pollId, - ).run(); - }); + setState(() => _future = _fetch()); } void _applyState(GetPollStateResponse state) { @@ -71,7 +63,7 @@ class _PollSheetBodyState extends State<_PollSheetBody> { // Block body: an arrow would return the Future, which setState rejects. _future = Future.value(state); }); - widget.refetch(renew: true); + widget.refetch(); } @override diff --git a/lib/view/pages/talk/widgets/chat_bubble_reactions.dart b/lib/view/pages/talk/widgets/chat_bubble_reactions.dart index 9583670..ec4e40b 100644 --- a/lib/view/pages/talk/widgets/chat_bubble_reactions.dart +++ b/lib/view/pages/talk/widgets/chat_bubble_reactions.dart @@ -17,7 +17,7 @@ class ChatBubbleReactions extends StatelessWidget { final GetChatResponseObject bubbleData; final GetRoomResponseObject chatData; final bool isSender; - final void Function({bool renew}) onChanged; + final VoidCallback onChanged; const ChatBubbleReactions({ required this.bubbleData, @@ -58,7 +58,7 @@ class ChatBubbleReactions extends StatelessWidget { params: ReactMessageParams(e.key), ).run(); } - onChanged(renew: true); + onChanged(); }); } 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 9f6244d..c855d4b 100644 --- a/lib/view/pages/talk/widgets/chat_message_options_dialog.dart +++ b/lib/view/pages/talk/widgets/chat_message_options_dialog.dart @@ -59,7 +59,7 @@ void showChatMessageOptionsDialog( required GetRoomResponseObject chatData, required GetChatResponseObject bubbleData, required bool isSender, - required void Function({bool renew}) onRefetch, + required VoidCallback onRefetch, }) { final parentContext = context; final canReact = @@ -196,7 +196,7 @@ class _ReactionsRow extends StatefulWidget { final int messageId; final List reactions; final List messageReactions; - final void Function({bool renew}) onRefetch; + final VoidCallback onRefetch; final BuildContext sheetContext; const _ReactionsRow({ @@ -232,7 +232,7 @@ class _ReactionsRowState extends State<_ReactionsRow> { }); if (!mounted) return; if (ok) { - widget.onRefetch(renew: true); + widget.onRefetch(); if (widget.sheetContext.mounted) Navigator.of(widget.sheetContext).pop(); } } diff --git a/lib/view/pages/talk/widgets/chat_textfield.dart b/lib/view/pages/talk/widgets/chat_textfield.dart index 2ef9438..5ff92fe 100644 --- a/lib/view/pages/talk/widgets/chat_textfield.dart +++ b/lib/view/pages/talk/widgets/chat_textfield.dart @@ -105,9 +105,7 @@ class _ChatTextfieldState extends State { if (messageId != null) { talkSettings.draftReplies[widget.sendToToken] = messageId; } else { - talkSettings.draftReplies.removeWhere( - (key, _) => key == widget.sendToToken, - ); + talkSettings.draftReplies.remove(widget.sendToToken); } } @@ -285,43 +283,36 @@ class _ChatTextfieldState extends State { @override Widget build(BuildContext context) { final chatBloc = context.read(); - // Only the reply reference is rendered from the chat state; loading flags - // and paging emits don't need to rebuild the input. - final (referenceMessageId, chatResponse) = context.select( - (ChatBloc b) => ( - b.state.data?.referenceMessageId, - b.state.data?.chatResponse, - ), - ); + // Only the resolved reply reference is rendered from the chat state; + // incoming messages, loading flags and paging emits don't rebuild the input. + final referenceMessage = context.select((ChatBloc b) { + final data = b.state.data; + final id = data?.referenceMessageId; + if (id == null) return null; + return data!.chatResponse?.data.where((e) => e.id == id).firstOrNull; + }); Widget replyBanner = const SizedBox.shrink(); - if (referenceMessageId != null && chatResponse != null) { - try { - final referenceMessage = chatResponse.data.firstWhere( - (e) => e.id == referenceMessageId, - ); - replyBanner = Row( - children: [ - Expanded( - child: AnswerReference( - referenceMessage: referenceMessage, - selfId: widget.selfId, - ), + if (referenceMessage != null) { + replyBanner = Row( + children: [ + Expanded( + child: AnswerReference( + referenceMessage: referenceMessage, + selfId: widget.selfId, ), - IconButton( - tooltip: 'Antwort verwerfen', - onPressed: () { - chatBloc.setReferenceMessageId(null); - _setDraftReply(null); - }, - icon: const Icon(Icons.close_outlined), - padding: const EdgeInsets.only(left: 0), - ), - ], - ); - } catch (_) { - /* reference no longer in current chat data */ - } + ), + IconButton( + tooltip: 'Antwort verwerfen', + onPressed: () { + chatBloc.setReferenceMessageId(null); + _setDraftReply(null); + }, + icon: const Icon(Icons.close_outlined), + padding: const EdgeInsets.only(left: 0), + ), + ], + ); } WidgetsBinding.instance.addPostFrameCallback((_) => _publishHeight()); diff --git a/lib/view/pages/talk/widgets/chat_tile.dart b/lib/view/pages/talk/widgets/chat_tile.dart index 1aac3f5..2ba4278 100644 --- a/lib/view/pages/talk/widgets/chat_tile.dart +++ b/lib/view/pages/talk/widgets/chat_tile.dart @@ -17,13 +17,13 @@ import '../../../../state/app/modules/chat_list/bloc/chat_list_bloc.dart'; import '../../../../utils/haptics.dart'; import '../../../../widget/a11y/a11y_labels.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 '../data/talk_markdown.dart'; import '../talk_navigator.dart'; import 'notification_level_sheet.dart'; +import 'room_actions.dart'; class ChatTile extends StatefulWidget { final GetRoomResponseObject data; @@ -48,11 +48,12 @@ class ChatTile extends StatefulWidget { } class _ChatTileState extends State { - String? selfUsername; + String? selfUsername = SessionManager().current?.nextcloud?.username; @override void initState() { super.initState(); + if (selfUsername != null) return; SessionManager().waitForLoad().then((session) { if (!mounted) return; setState(() => selfUsername = session?.nextcloud?.username); @@ -124,9 +125,8 @@ class _ChatTileState extends State { label: isGroup ? A11yLabels.group : null, child: ExcludeSemantics(child: circleAvatar), ), - Visibility( - visible: widget.data.isFavorite, - child: Positioned( + if (widget.data.isFavorite) + Positioned( right: 0, bottom: 0, child: Container( @@ -143,7 +143,6 @@ class _ChatTileState extends State { ), ), ), - ), ], ), title: Row( @@ -241,24 +240,16 @@ class _ChatTileState extends State { if (mounted) _refreshList(); }, ), - if (widget.data.isFavorite) - AsyncListTile( - leading: const Icon(Icons.stars_outlined), - title: const Text('Von Favoriten entfernen'), - onPressed: () async { - await SetFavorite(widget.data.token, false).run(); - if (mounted) _refreshList(); - }, - ) - else - AsyncListTile( - leading: const Icon(Icons.star_outline), - title: const Text('Zu Favoriten hinzufügen'), - onPressed: () async { - await SetFavorite(widget.data.token, true).run(); - if (mounted) _refreshList(); - }, - ), + FavoriteToggleTile( + isFavorite: widget.data.isFavorite, + onPressed: () async { + await SetFavorite( + widget.data.token, + !widget.data.isFavorite, + ).run(); + if (mounted) _refreshList(); + }, + ), NotificationLevelTile( level: widget.data.effectiveNotificationLevel, onTap: () { @@ -275,16 +266,7 @@ class _ChatTileState extends State { title: const Text('Talk-Chat verlassen'), onTap: () { Navigator.of(sheetCtx).pop(); - ConfirmDialog( - title: 'Talk-Chat verlassen', - content: - 'Du benötigst ggf. eine Einladung um erneut beizutreten.', - confirmButton: 'Verlassen', - onConfirmAsync: () async { - await LeaveRoom(widget.data.token).run(); - if (mounted) _refreshList(); - }, - ).asDialog(context); + confirmLeaveRoom(context, widget.data.token); }, ), DebugTile(sheetCtx).jsonData(widget.data.toJson()), diff --git a/lib/view/pages/talk/widgets/highlighted_linkify.dart b/lib/view/pages/talk/widgets/highlighted_linkify.dart index e335f91..351553a 100644 --- a/lib/view/pages/talk/widgets/highlighted_linkify.dart +++ b/lib/view/pages/talk/widgets/highlighted_linkify.dart @@ -62,14 +62,12 @@ class HighlightedLinkify extends StatefulWidget { final String? highlight; final LinkCallback? onOpen; final TextStyle? style; - final TextStyle? linkStyle; const HighlightedLinkify({ required this.text, this.highlight, this.onOpen, this.style, - this.linkStyle, super.key, }); @@ -126,11 +124,7 @@ class _HighlightedLinkifyState extends State { // drops link color/underline because TextStyle.merge treats explicit // nulls in the overlay as "leave unchanged". final linkStyle = defaultStyle.merge( - widget.linkStyle ?? - const TextStyle( - color: Colors.blue, - decoration: TextDecoration.underline, - ), + const TextStyle(color: Colors.blue, decoration: TextDecoration.underline), ); const linkHighlight = TextStyle( backgroundColor: Color(0xFFFFD54F), diff --git a/lib/view/pages/talk/widgets/poll_options_list.dart b/lib/view/pages/talk/widgets/poll_options_list.dart index 593cb38..560f0b8 100644 --- a/lib/view/pages/talk/widgets/poll_options_list.dart +++ b/lib/view/pages/talk/widgets/poll_options_list.dart @@ -78,22 +78,12 @@ class _PollOptionsListState extends State { return !(voted.length == _selected.length && voted.containsAll(_selected)); } - Future _submitVote() async { + Future _vote(List optionIds) async { if (guardDemoAction(context)) return; final result = await VotePoll( token: widget.room.token, pollId: widget.pollData.id, - params: VotePollParams(optionIds: _selected.toList()..sort()), - ).run(); - widget.onStateChanged(result); - } - - Future _retractVote() async { - if (guardDemoAction(context)) return; - final result = await VotePoll( - token: widget.room.token, - pollId: widget.pollData.id, - params: VotePollParams(optionIds: const []), + params: VotePollParams(optionIds: optionIds), ).run(); widget.onStateChanged(result); } @@ -230,22 +220,22 @@ class _PollOptionsListState extends State { Widget _primaryButton() { final voted = widget.pollData.votedSelf.isNotEmpty; + final canSubmit = _selected.isNotEmpty && _selectionChanged; // Retract stays a flat AsyncTextButton – its spinner uses colorScheme.primary // and stays visible on the sheet background, unlike the filled variant's. - if (voted && !(_selected.isNotEmpty && _selectionChanged)) { + if (voted && !canSubmit) { return AsyncTextButton( controller: _voteController, - onPressed: _retractVote, + onPressed: () => _vote(const []), showInlineError: false, child: const Text('Zurückziehen'), ); } - final canSubmit = _selected.isNotEmpty && _selectionChanged; return AsyncActionButton( controller: _voteController, - onPressed: canSubmit ? _submitVote : null, + onPressed: canSubmit ? () => _vote(_selected.toList()..sort()) : null, showInlineError: false, style: ElevatedButton.styleFrom( backgroundColor: Theme.of(context).colorScheme.primary, diff --git a/lib/view/pages/talk/widgets/room_actions.dart b/lib/view/pages/talk/widgets/room_actions.dart new file mode 100644 index 0000000..4935d1a --- /dev/null +++ b/lib/view/pages/talk/widgets/room_actions.dart @@ -0,0 +1,48 @@ +import 'dart:async'; + +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; + +import '../../../../api/marianumcloud/talk/actions/talk_actions.dart'; +import '../../../../state/app/modules/chat_list/bloc/chat_list_bloc.dart'; +import '../../../../widget/async_action_button.dart'; +import '../../../../widget/confirm_dialog.dart'; + +class FavoriteToggleTile extends StatelessWidget { + final bool isFavorite; + final AsyncActionCallback onPressed; + + const FavoriteToggleTile({ + required this.isFavorite, + required this.onPressed, + super.key, + }); + + @override + Widget build(BuildContext context) => AsyncListTile( + leading: Icon(isFavorite ? Icons.stars_outlined : Icons.star_outline), + title: Text( + isFavorite ? 'Von Favoriten entfernen' : 'Zu Favoriten hinzufügen', + ), + onPressed: onPressed, + ); +} + +/// Asks for confirmation, leaves the room and refreshes the chat list. +/// Resolves to true once the room was left. +Future confirmLeaveRoom(BuildContext context, String token) async { + final chatList = context.read(); + final left = await showDialog( + context: context, + builder: (_) => ConfirmDialog( + title: 'Talk-Chat verlassen', + content: 'Du benötigst ggf. eine Einladung um erneut beizutreten.', + confirmButton: 'Verlassen', + onConfirmAsync: () async { + await LeaveRoom(token).run(); + unawaited(chatList.refresh()); + }, + ), + ); + return left ?? false; +} diff --git a/lib/view/pages/ticker/ticker_view.dart b/lib/view/pages/ticker/ticker_view.dart index 5b1ce6d..591a731 100644 --- a/lib/view/pages/ticker/ticker_view.dart +++ b/lib/view/pages/ticker/ticker_view.dart @@ -127,9 +127,9 @@ class _TickerScaffoldState extends State { void initState() { super.initState(); final initial = widget.initialSlug; - if (initial != null && _isSelectable(widget.sections, initial)) { + if (initial != null && _isSelectable(initial)) { _selectedSlug = initial; - _selectedTitle = _titleForSlug(initial); + _selectedTitle = _pageFor(initial)?.title; } else if (initial != null) { // Remembered page is gone (deleted/hidden) → drop the stale slug once // mounted and stay on "Aktuelles". @@ -155,7 +155,7 @@ class _TickerScaffoldState extends State { // A fresh nav can drop the open page (deleted/hidden/unpublished server // side) → fall back to "Aktuelles". final slug = _selectedSlug; - if (slug != null && !_isSelectable(widget.sections, slug)) _selectHome(); + if (slug != null && !_isSelectable(slug)) _selectHome(); } @override @@ -165,13 +165,18 @@ class _TickerScaffoldState extends State { super.dispose(); } - bool _isSelectable(List sections, String slug) { - for (final section in sections) { + TickerNavPage? _pageFor(String slug) { + for (final section in widget.sections) { for (final page in section.pages) { - if (page.slug == slug) return page.kind != TickerPageKind.redirect; + if (page.slug == slug) return page; } } - return false; + return null; + } + + bool _isSelectable(String slug) { + final page = _pageFor(slug); + return page != null && page.kind != TickerPageKind.redirect; } void _ensureBackEntry() { @@ -236,12 +241,14 @@ class _TickerScaffoldState extends State { _removeBackEntry(); } - void _selectPage(TickerNavPage page) { + void _selectPage(TickerNavPage page) => _select(page.slug, page.title); + + void _select(String slug, String? title) { setState(() { - _selectedSlug = page.slug; - _selectedTitle = page.title; + _selectedSlug = slug; + _selectedTitle = title; }); - widget.onSelectionChanged?.call(page.slug); + widget.onSelectionChanged?.call(slug); _ensureBackEntry(); } @@ -255,26 +262,12 @@ class _TickerScaffoldState extends State { void _onLinkTap(String href) { final slug = AppRoutes.tickerSlugOf(href); if (slug != null && slug.isNotEmpty) { - setState(() { - _selectedSlug = slug; - _selectedTitle = _titleForSlug(slug); - }); - widget.onSelectionChanged?.call(slug); - _ensureBackEntry(); + _select(slug, _pageFor(slug)?.title); return; } unawaited(UrlOpener.openUrl(href)); } - String? _titleForSlug(String slug) { - for (final section in widget.sections) { - for (final page in section.pages) { - if (page.slug == slug) return page.title; - } - } - return null; - } - @override Widget build(BuildContext context) => LayoutBuilder( builder: (context, constraints) { diff --git a/lib/view/pages/ticker/widgets/ticker_page_body.dart b/lib/view/pages/ticker/widgets/ticker_page_body.dart index ba64c1a..27caa07 100644 --- a/lib/view/pages/ticker/widgets/ticker_page_body.dart +++ b/lib/view/pages/ticker/widgets/ticker_page_body.dart @@ -98,7 +98,9 @@ class _TickerPageContentState extends State<_TickerPageContent> { children: [ // For proxied files the data currency is the last successful // proxy fetch, not the page's publish date. - TickerUpdatedBar(publishedAt: page.fileFetchedAt ?? page.publishedAt), + TickerUpdatedBar( + publishedAt: page.fileFetchedAt ?? page.publishedAt, + ), Expanded( child: _ProxiedFileView( repo: context.read().repo, diff --git a/lib/view/pages/timetable/custom_events/custom_event_edit_dialog.dart b/lib/view/pages/timetable/custom_events/custom_event_edit_dialog.dart index afdb8ea..c74a3ef 100644 --- a/lib/view/pages/timetable/custom_events/custom_event_edit_dialog.dart +++ b/lib/view/pages/timetable/custom_events/custom_event_edit_dialog.dart @@ -13,6 +13,7 @@ import '../../../../widget/async_action_button.dart'; import '../../../../widget/demo_restricted.dart'; import '../../../../widget/focus_behaviour.dart'; import '../data/subject_color_palette.dart'; +import '../data/timetable_appointment_factory.dart'; import '../subject_colors/subject_color_picker.dart'; class CustomEventEditDialog extends StatefulWidget { @@ -72,9 +73,10 @@ class _CustomEventEditDialogState extends State { void initState() { super.initState(); if (_isEditing) { - final s = widget.existingEvent!.startDate; - final e = widget.existingEvent!.endDate; - _isAllDay = isAllDayConvention(s, e); + final existing = widget.existingEvent!; + final s = existing.startDate; + final e = existing.endDate; + _isAllDay = TimetableAppointmentFactory.isCustomEventAllDay(existing); if (_isAllDay) { _startTime = _defaultStart; _endTime = _defaultEnd; @@ -119,21 +121,6 @@ class _CustomEventEditDialogState extends State { return (fromMin(start), fromMin(end)); } - /// All-day convention shared with [TimetableAppointmentFactory]: a custom - /// event is treated as all-day when its start and end both land on midnight - /// of the same day. We piggyback on this so we don't need a backend schema - /// change. - static bool isAllDayConvention(DateTime start, DateTime end) => - start.year == end.year && - start.month == end.month && - start.day == end.day && - start.hour == 0 && - start.minute == 0 && - start.second == 0 && - end.hour == 0 && - end.minute == 0 && - end.second == 0; - Future _save() async { if (guardDemoAction(context)) return; if (_name.text.trim().isEmpty) { @@ -142,7 +129,7 @@ class _CustomEventEditDialogState extends State { // All-day convention: store start and end as midnight of the chosen day. // The factory recognises this on read. - final midnight = DateTime(_date.year, _date.month, _date.day); + final midnight = _date.dateOnly; final startDate = _isAllDay ? midnight : _date.withTime(_startTime); final endDate = _isAllDay ? midnight : _date.withTime(_endTime); diff --git a/lib/view/pages/timetable/data/calendar_logic.dart b/lib/view/pages/timetable/data/calendar_logic.dart index 54b9dd7..76f5f86 100644 --- a/lib/view/pages/timetable/data/calendar_logic.dart +++ b/lib/view/pages/timetable/data/calendar_logic.dart @@ -29,7 +29,7 @@ bool isOutsideSchoolHours(Appointment a) { } int dayIndex(DateTime t, DateTime weekStart) => - DateTime(t.year, t.month, t.day).difference(weekStart).inDays; + t.dateOnly.difference(weekStart).inDays; class BoundRegion { final TimeRegion region; @@ -41,7 +41,7 @@ class BoundRegion { List expandRegionsForDay(List regions, DateTime day) { final result = []; - final dayStart = DateTime(day.year, day.month, day.day); + final dayStart = day.dateOnly; for (final region in regions) { final isRecurringDaily = region.recurrenceRule != null && @@ -123,11 +123,7 @@ partitionAppointmentsForWeek( )) { continue; } - final idx = DateTime( - occLocal.year, - occLocal.month, - occLocal.day, - ).difference(weekStart).inDays; + final idx = dayIndex(occLocal, weekStart); if (idx < 0 || idx >= 5) continue; final newStart = DateTime( occLocal.year, diff --git a/lib/view/pages/timetable/data/timetable_appointment_factory.dart b/lib/view/pages/timetable/data/timetable_appointment_factory.dart index 0f9fa69..7b2c7f7 100644 --- a/lib/view/pages/timetable/data/timetable_appointment_factory.dart +++ b/lib/view/pages/timetable/data/timetable_appointment_factory.dart @@ -5,6 +5,7 @@ import '../../../../api/marianumconnect/models/mc_holiday.dart'; import '../../../../api/marianumconnect/queries/timetable_get_subjects/timetable_get_subjects_response.dart'; import '../../../../api/marianumconnect/queries/timetable_get_week/timetable_get_week_response.dart'; import '../../../../api/mhsl/custom_timetable_event/custom_timetable_event.dart'; +import '../../../../extensions/date_time.dart'; import '../../../../storage/timetable_settings.dart'; import 'arbitrary_appointment.dart'; import 'lesson_color.dart'; @@ -150,16 +151,8 @@ class TimetableAppointmentFactory { List _holidayExceptionDates(CustomTimetableEvent event) { final result = []; for (final holiday in holidays) { - var day = DateTime( - holiday.startDate.year, - holiday.startDate.month, - holiday.startDate.day, - ); - final last = DateTime( - holiday.endDate.year, - holiday.endDate.month, - holiday.endDate.day, - ); + var day = holiday.startDate.dateOnly; + final last = holiday.endDate.dateOnly; while (!day.isAfter(last)) { result.add( DateTime( @@ -171,7 +164,7 @@ class TimetableAppointmentFactory { event.startDate.second, ), ); - day = DateTime(day.year, day.month, day.day + 1); + day = day.addDays(1); } } return result; diff --git a/lib/view/pages/timetable/details/custom_event_sheet.dart b/lib/view/pages/timetable/details/custom_event_sheet.dart index cd73ad7..827a0d7 100644 --- a/lib/view/pages/timetable/details/custom_event_sheet.dart +++ b/lib/view/pages/timetable/details/custom_event_sheet.dart @@ -54,11 +54,8 @@ class CustomEventSheet { ), TextButton.icon( onPressed: () { - showDeleteCustomEventDialog(context, event).future.then(( - _, - ) { - if (!sheetCtx.mounted) return; - Navigator.of(sheetCtx).pop(); + showDeleteCustomEventDialog(context, event).then((_) { + if (sheetCtx.mounted) Navigator.of(sheetCtx).pop(); }); }, label: const Text('Löschen'), diff --git a/lib/view/pages/timetable/details/delete_custom_event.dart b/lib/view/pages/timetable/details/delete_custom_event.dart index fa6f70c..3174064 100644 --- a/lib/view/pages/timetable/details/delete_custom_event.dart +++ b/lib/view/pages/timetable/details/delete_custom_event.dart @@ -8,12 +8,13 @@ import '../../../../state/app/modules/timetable/bloc/timetable_bloc.dart'; import '../../../../widget/confirm_dialog.dart'; import '../../../../widget/demo_restricted.dart'; -Completer showDeleteCustomEventDialog( +/// Completes once the event was deleted; never when the dialog is dismissed. +Future showDeleteCustomEventDialog( BuildContext context, CustomTimetableEvent event, ) { final completer = Completer(); - if (guardDemoAction(context)) return completer; + if (guardDemoAction(context)) return completer.future; final bloc = context.read(); ConfirmDialog( title: 'Termin löschen', @@ -25,5 +26,5 @@ Completer showDeleteCustomEventDialog( completer.complete(); }, ).asDialog(context); - return completer; + return completer.future; } diff --git a/lib/view/pages/timetable/details/lesson_sheet.dart b/lib/view/pages/timetable/details/lesson_sheet.dart index d402d04..d205f8d 100644 --- a/lib/view/pages/timetable/details/lesson_sheet.dart +++ b/lib/view/pages/timetable/details/lesson_sheet.dart @@ -3,6 +3,7 @@ import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:syncfusion_flutter_calendar/calendar.dart'; import '../../../../api/marianumconnect/queries/timetable_get_element_week/timetable_element_type.dart'; +import '../../../../api/marianumconnect/queries/timetable_get_subjects/timetable_get_subjects_response.dart'; import '../../../../api/marianumconnect/queries/timetable_get_week/timetable_get_week_response.dart'; import '../../../../extensions/date_time.dart'; import '../../../../extensions/text.dart'; @@ -31,12 +32,11 @@ class LessonSheet { // ändern, ohne dass sich der fremde Plan aktualisiert — daher nur im // eigenen Plan anbieten. Die Einfärbung (eigene Farben) bleibt. final bloc = context.read(); + final subjects = state.subjects?.result ?? const []; + McSubject? subjectFor(String shortName) => + subjects.where((s) => s.shortName == shortName).firstOrNull; final subjectShort = lesson.subjects.firstOrNull; - final subjectEntry = subjectShort == null - ? null - : state.subjects?.result - .where((s) => s.shortName == subjectShort) - .firstOrNull; + final subjectEntry = subjectShort == null ? null : subjectFor(subjectShort); final headerLong = subjectEntry?.longName; // Bei Stunden ohne Fach (Pausenaufsicht etc.) den Lesson-Type-Titel // einsetzen — sonst stünde im Header nur ein generisches "?". @@ -85,9 +85,7 @@ class LessonSheet { icon: Icons.book_outlined, label: 'Fächer', entries: lesson.subjects - .map( - (s) => _line(s, longname: _subjectLongName(state.subjects, s)), - ) + .map((s) => _line(s, longname: subjectFor(s)?.longName)) .toList(), ), _roomTile(context, lesson), @@ -120,14 +118,10 @@ class LessonSheet { ); } - final entries = lesson.rooms - .map((name) => (main: _line(name), sub: null as String?)) - .toList(); - - return _listTileWithSubs( + return _listTile( icon: Icons.room, label: lesson.rooms.length == 1 ? 'Raum' : 'Räume', - entries: entries, + entries: lesson.rooms.map(_line).toList(), trailing: trailing, ); } @@ -229,43 +223,6 @@ class LessonSheet { ); } - static Widget _listTileWithSubs({ - required IconData icon, - required String label, - required List<({String main, String? sub})> entries, - Widget? trailing, - }) { - if (entries.length == 1) { - final e = entries.first; - return ListTile( - leading: Icon(icon), - title: Text('$label: ${e.main}'), - subtitle: e.sub != null ? Text(e.sub!) : null, - trailing: trailing, - ); - } - return ListTile( - leading: Icon(icon), - title: Text(label), - subtitle: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: entries - .expand( - (e) => [ - Text(e.main), - if (e.sub != null) - Padding( - padding: const EdgeInsets.only(left: 12), - child: Text(e.sub!), - ), - ], - ) - .toList(), - ), - trailing: trailing, - ); - } - static Widget _listTile({ required IconData icon, required String label, @@ -347,15 +304,6 @@ class LessonSheet { return parts.join(' '); } - static String? _subjectLongName(dynamic subjects, String shortName) { - if (subjects == null) return null; - final list = subjects.result as Iterable; - for (final s in list) { - if (s.shortName == shortName) return s.longName as String?; - } - return null; - } - static IconData _iconForStatus(String status) { switch (status) { case 'CANCELLED': diff --git a/lib/view/pages/timetable/timetable.dart b/lib/view/pages/timetable/timetable.dart index 23c178e..998dc4e 100644 --- a/lib/view/pages/timetable/timetable.dart +++ b/lib/view/pages/timetable/timetable.dart @@ -10,8 +10,10 @@ import '../../../state/app/modules/foreign_timetable/bloc/foreign_timetable_bloc import '../../../state/app/modules/settings/bloc/settings_cubit.dart'; import '../../../state/app/modules/timetable/bloc/timetable_bloc.dart'; import '../../../state/app/modules/timetable/bloc/timetable_state.dart'; +import '../../../state/app/modules/timetable/bloc/timetable_week_loader.dart'; import '../../../utils/haptics.dart'; import '../../../widget/demo_restricted.dart'; +import '../foreign_timetable/element_type_icon.dart'; import 'custom_events/custom_event_edit_dialog.dart'; import 'details/appointment_details_dispatcher.dart'; import 'widgets/timetable_calendar_view.dart'; @@ -33,14 +35,8 @@ class _TimetableState extends State { /// user's own. Cleared (back to own plan) via the viewing banner. TimetableElementRef? _selected; - DateTime _initialDisplayDate() => DateTime.now().addDays(2); - - void _jumpToToday() { - _calendarKey.currentState?.jumpToToday(); - } - - bool _isOnInitialWeek(TimetableState state) => - state.startDate == _mondayOf(_initialDisplayDate()); + static bool _isOnInitialWeek(TimetableState? state) => + state != null && state.startDate == timetableAnchorDate().mondayOfWeek; Future _openPicker() async { if (guardDemoAction(context)) return; @@ -57,20 +53,7 @@ class _TimetableState extends State { setState(() => _selected = null); } - void _onAction(_CalendarAction action) { - switch (action) { - case _CalendarAction.addEvent: - showDialog( - context: context, - builder: (_) => const CustomEventEditDialog(), - barrierDismissible: false, - ); - case _CalendarAction.viewEvents: - AppRoutes.openCustomEvents(context); - } - } - - void _onCreateEventAt(DateTime start, DateTime end) { + void _showEventEditor({DateTime? start, DateTime? end}) { showDialog( context: context, builder: (_) => @@ -79,6 +62,15 @@ class _TimetableState extends State { ); } + void _onAction(_CalendarAction action) { + switch (action) { + case _CalendarAction.addEvent: + _showEventEditor(); + case _CalendarAction.viewEvents: + AppRoutes.openCustomEvents(context); + } + } + @override Widget build(BuildContext context) { final selected = _selected; @@ -87,11 +79,8 @@ class _TimetableState extends State { // (or back to the own plan) tears it down and builds a fresh one. return BlocProvider( key: ValueKey('${selected.type.name}-${selected.id}'), - create: (_) => ForeignTimetableBloc( - type: selected.type, - elementId: selected.id, - title: selected.label, - ), + create: (_) => + ForeignTimetableBloc(type: selected.type, elementId: selected.id), // Builder gives us a context *below* the provider so the foreign bloc is // resolvable inside _buildForeignPlan. child: Builder( @@ -100,55 +89,68 @@ class _TimetableState extends State { ); } + AppBar _appBar({ + required bool atToday, + required bool canViewForeign, + bool withEventMenu = false, + }) => AppBar( + // Der Kalender scrollt nicht (nur ziehen/reloaden), aber seine internen + // Scrollables feuern ScrollNotifications, die sonst den Material-3 + // "scrolled under"-Farbwechsel der AppBar dauerhaft auslösen. + notificationPredicate: (_) => false, + title: const Text('Stunden & Vertretungsplan'), + actions: [ + IconButton( + icon: const Icon(Icons.home_outlined), + tooltip: 'Zur aktuellen Woche', + onPressed: atToday + ? null + : () => _calendarKey.currentState?.jumpToToday(), + ), + if (withEventMenu) + PopupMenuButton<_CalendarAction>( + tooltip: 'Kalendereinträge', + icon: const Icon(Icons.edit_calendar_outlined), + onSelected: _onAction, + itemBuilder: (_) => const [ + PopupMenuItem( + value: _CalendarAction.addEvent, + child: ListTile( + title: Text('Kalendereintrag hinzufügen'), + leading: Icon(Icons.add), + ), + ), + PopupMenuItem( + value: _CalendarAction.viewEvents, + child: ListTile( + title: Text('Kalendereinträge anzeigen'), + leading: Icon(Icons.perm_contact_calendar_outlined), + ), + ), + ], + ), + if (canViewForeign) + IconButton( + icon: const Icon(Icons.person_search), + tooltip: 'Anderen Stundenplan öffnen', + onPressed: _openPicker, + ), + ], + ); + Widget _buildOwnPlan(BuildContext context) { final bloc = context.read(); - final loadableState = context.watch().state; - final innerState = loadableState.data; - final atToday = innerState != null && _isOnInitialWeek(innerState); + final atToday = context.select( + (TimetableBloc b) => _isOnInitialWeek(b.state.data), + ); final capabilities = context.watch(); final canViewForeign = capabilities.canViewForeignTimetables; final teacherPlan = capabilities.isTeacher; return Scaffold( - appBar: AppBar( - // Der Kalender scrollt nicht (nur ziehen/reloaden), aber seine internen - // Scrollables feuern ScrollNotifications, die sonst den Material-3 - // "scrolled under"-Farbwechsel der AppBar dauerhaft auslösen. - notificationPredicate: (_) => false, - title: const Text('Stunden & Vertretungsplan'), - actions: [ - IconButton( - icon: const Icon(Icons.home_outlined), - tooltip: 'Zur aktuellen Woche', - onPressed: atToday ? null : _jumpToToday, - ), - PopupMenuButton<_CalendarAction>( - tooltip: 'Kalendereinträge', - icon: const Icon(Icons.edit_calendar_outlined), - onSelected: _onAction, - itemBuilder: (_) => const [ - PopupMenuItem( - value: _CalendarAction.addEvent, - child: ListTile( - title: Text('Kalendereintrag hinzufügen'), - leading: Icon(Icons.add), - ), - ), - PopupMenuItem( - value: _CalendarAction.viewEvents, - child: ListTile( - title: Text('Kalendereinträge anzeigen'), - leading: Icon(Icons.perm_contact_calendar_outlined), - ), - ), - ], - ), - if (canViewForeign) - IconButton( - icon: const Icon(Icons.person_search), - tooltip: 'Anderen Stundenplan öffnen', - onPressed: _openPicker, - ), - ], + appBar: _appBar( + atToday: atToday, + canViewForeign: canViewForeign, + withEventMenu: true, ), body: LoadableStateConsumer( // Without this predicate the consumer treats the freshly-initialised @@ -170,7 +172,8 @@ class _TimetableState extends State { teacherPlan: teacherPlan, onOpenElement: canViewForeign ? _openElement : null, ), - onCreateEvent: _onCreateEventAt, + onCreateEvent: (start, end) => + _showEventEditor(start: start, end: end), customEvents: state.customEvents?.events ?? const [], showClassInsteadOfTeacher: teacherPlan, ), @@ -180,44 +183,21 @@ class _TimetableState extends State { Widget _buildForeignPlan(BuildContext context, TimetableElementRef selected) { final bloc = context.read(); - final loadableState = context.watch().state; - final innerState = loadableState.data; - final atToday = innerState != null && _isOnInitialWeek(innerState); - final canViewForeign = context - .watch() - .canViewForeignTimetables; + final atToday = context.select( + (ForeignTimetableBloc b) => _isOnInitialWeek(b.state.data), + ); + final canViewForeign = context.select( + (CapabilitiesCubit c) => c.canViewForeignTimetables, + ); final teacherPlan = selected.type == TimetableElementType.teacher; return Scaffold( - appBar: AppBar( - // Siehe _buildOwnPlan: den scroll-under-Farbwechsel unterdrücken, weil - // der Kalender nicht scrollt, aber ScrollNotifications feuert. - notificationPredicate: (_) => false, - title: const Text('Stunden & Vertretungsplan'), - actions: [ - IconButton( - icon: const Icon(Icons.home_outlined), - tooltip: 'Zur aktuellen Woche', - onPressed: atToday ? null : _jumpToToday, - ), - if (canViewForeign) - IconButton( - icon: const Icon(Icons.person_search), - tooltip: 'Anderen Stundenplan öffnen', - onPressed: _openPicker, - ), - ], - ), + appBar: _appBar(atToday: atToday, canViewForeign: canViewForeign), body: Column( children: [ _ViewingBanner(element: selected, onClose: _backToOwnPlan), Expanded( child: LoadableStateConsumer( - // Foreign plans never carry custom events, so unlike the own-plan - // view we must not require `customEvents` here. - isReady: (state) => - state.rooms != null && - state.subjects != null && - state.schoolHolidays != null, + isReady: (state) => state.hasSchoolReferenceData, child: (state, _) => TimetableCalendarView( key: _calendarKey, state: state, @@ -229,7 +209,6 @@ class _TimetableState extends State { teacherPlan: teacherPlan, onOpenElement: canViewForeign ? _openElement : null, ), - customEvents: const [], showClassInsteadOfTeacher: teacherPlan, ), ), @@ -238,11 +217,6 @@ class _TimetableState extends State { ), ); } - - static DateTime _mondayOf(DateTime d) { - final monday = d.subtractDays(d.weekday - 1); - return DateTime(monday.year, monday.month, monday.day); - } } /// Slim banner shown at the top of the timetable while a foreign element's plan @@ -297,7 +271,7 @@ class _ViewingBanner extends StatelessWidget { padding: const EdgeInsets.fromLTRB(16, 2, 6, 2), child: Row( children: [ - Icon(_iconFor(element.type), size: 16, color: onColor), + Icon(element.type.icon, size: 16, color: onColor), const SizedBox(width: 10), Expanded( child: Text( @@ -324,17 +298,4 @@ class _ViewingBanner extends StatelessWidget { ), ); } - - static IconData _iconFor(TimetableElementType type) { - switch (type) { - case TimetableElementType.student: - return Icons.person_outline; - case TimetableElementType.teacher: - return Icons.school_outlined; - case TimetableElementType.room: - return Icons.meeting_room_outlined; - case TimetableElementType.schoolClass: - return Icons.groups_outlined; - } - } } diff --git a/lib/view/pages/timetable/widgets/calendar/week_grid.dart b/lib/view/pages/timetable/widgets/calendar/week_grid.dart index e2c6e0e..009a4a3 100644 --- a/lib/view/pages/timetable/widgets/calendar/week_grid.dart +++ b/lib/view/pages/timetable/widgets/calendar/week_grid.dart @@ -253,20 +253,8 @@ class _DayColumn extends StatelessWidget { final period = layout.periodAtY(details.localPosition.dy); if (period == null) return; - final start = DateTime( - date.year, - date.month, - date.day, - period.start.hour, - period.start.minute, - ); - final end = DateTime( - date.year, - date.month, - date.day, - period.end.hour, - period.end.minute, - ); + final start = date.withTime(period.start); + final end = date.withTime(period.end); if (_overlapsExistingAppointment(start, end, dayAppts)) return; Haptics.longPress(); diff --git a/lib/view/pages/timetable/widgets/custom_workweek_calendar.dart b/lib/view/pages/timetable/widgets/custom_workweek_calendar.dart index 6f4f108..bec3982 100644 --- a/lib/view/pages/timetable/widgets/custom_workweek_calendar.dart +++ b/lib/view/pages/timetable/widgets/custom_workweek_calendar.dart @@ -1,8 +1,8 @@ /// Custom 5-day work-week calendar (replaces Syncfusion's `WorkWeek` view). /// /// Implementation is split across `calendar/` for readability; everything -/// stays in this single library so private widgets and helpers (`_DayColumn`, -/// `_PeriodLayout`, `_isAllDayLike`, …) can remain library-private. +/// stays in this single library so private widgets (`_DayColumn`, +/// `_WeekGrid`, …) can remain library-private. library; import 'dart:async'; diff --git a/lib/view/pages/timetable/widgets/special_regions_builder.dart b/lib/view/pages/timetable/widgets/special_regions_builder.dart index 1b188f1..710893c 100644 --- a/lib/view/pages/timetable/widgets/special_regions_builder.dart +++ b/lib/view/pages/timetable/widgets/special_regions_builder.dart @@ -44,11 +44,7 @@ class SpecialRegionsBuilder { for (var day = rangeStart; !day.isAfter(rangeEnd); day = day.addDays(1)) { if (holidayDays.contains(_dayKey(day))) continue; for (final p in breakPeriods) { - final start = day.copyWith( - hour: p.start.hour, - minute: p.start.minute, - ); - breakRegions.add(_breakRegion(start, p.duration)); + breakRegions.add(_breakRegion(day.withTime(p.start), p.duration)); } } @@ -64,16 +60,8 @@ class SpecialRegionsBuilder { // on top of each other. final byDay = {}; for (final holiday in holidays.result) { - final startDay = DateTime( - holiday.startDate.year, - holiday.startDate.month, - holiday.startDate.day, - ); - final endDay = DateTime( - holiday.endDate.year, - holiday.endDate.month, - holiday.endDate.day, - ); + final startDay = holiday.startDate.dateOnly; + final endDay = holiday.endDate.dateOnly; // Webuntis treats endDate inclusively (last day of the break) — the // `+ 1` covers single-day public holidays (where startDate == endDate) // and the final day of a multi-day vacation, both of which would diff --git a/lib/view/pages/timetable/widgets/timetable_calendar_view.dart b/lib/view/pages/timetable/widgets/timetable_calendar_view.dart index c48f7fa..62236ca 100644 --- a/lib/view/pages/timetable/widgets/timetable_calendar_view.dart +++ b/lib/view/pages/timetable/widgets/timetable_calendar_view.dart @@ -7,6 +7,7 @@ import '../../../../extensions/date_time.dart'; import '../../../../state/app/modules/capabilities/bloc/capabilities_cubit.dart'; import '../../../../state/app/modules/settings/bloc/settings_cubit.dart'; import '../../../../state/app/modules/timetable/bloc/timetable_state.dart'; +import '../../../../state/app/modules/timetable/bloc/timetable_week_loader.dart'; import '../data/arbitrary_appointment.dart'; import '../data/lesson_period_schedule.dart'; import '../data/timetable_appointment_factory.dart'; @@ -63,12 +64,10 @@ class TimetableCalendarViewState extends State { List? _regions; Object? _regionsKey; - DateTime _initialDisplayDate() => DateTime.now().addDays(2); - /// Snaps the calendar back to the current week. Exposed so host pages can /// wire it to a "today" AppBar action. void jumpToToday() { - _calendarKey.currentState?.jumpToDate(_initialDisplayDate()); + _calendarKey.currentState?.jumpToDate(timetableAnchorDate()); } List _appointments(TimetableState state) { @@ -161,7 +160,7 @@ class TimetableCalendarViewState extends State { schedule: schedule, appointments: appointments, timeRegions: regions, - initialDate: _initialDisplayDate(), + initialDate: timetableAnchorDate(), minDate: minDate, maxDate: maxDate, onAppointmentTap: widget.onAppointmentTap, @@ -197,16 +196,14 @@ class TimetableCalendarViewState extends State { baseMin = now.subtractDays(14); baseMax = now.addDays(7); } - final effectiveMin = state.accessibleStartDate != null - ? (state.accessibleStartDate!.isAfter(baseMin) - ? state.accessibleStartDate! - : baseMin) - : baseMin; - final effectiveMax = state.accessibleEndDate != null - ? (state.accessibleEndDate!.isBefore(baseMax) - ? state.accessibleEndDate! - : baseMax) - : baseMax; + final accessibleStart = state.accessibleStartDate; + final accessibleEnd = state.accessibleEndDate; + final effectiveMin = accessibleStart == null + ? baseMin + : _later(accessibleStart, baseMin); + final effectiveMax = accessibleEnd == null + ? baseMax + : _earlier(accessibleEnd, baseMax); final today = DateTime.now().dateOnly; final todayMonday = today.mondayOfWeek; final currentWeekEnd = todayMonday.addDays(DateTime.daysPerWeek - 1); @@ -216,12 +213,12 @@ class TimetableCalendarViewState extends State { final capMax = futureDays == null ? null : _later(today.addDays(futureDays), currentWeekEnd); - final cappedMin = capMin != null && effectiveMin.isBefore(capMin) - ? capMin - : effectiveMin; - final cappedMax = capMax != null && effectiveMax.isAfter(capMax) - ? capMax - : effectiveMax; + final cappedMin = capMin == null + ? effectiveMin + : _later(effectiveMin, capMin); + final cappedMax = capMax == null + ? effectiveMax + : _earlier(effectiveMax, capMax); // When the resulting range does not cover the current week — the summer gap // between two school years, or a stale persisted bound — fall back to the // current week, widened to whatever the capabilities still allow. Otherwise diff --git a/lib/widget/async_actions/async_action_button.dart b/lib/widget/async_actions/async_action_button.dart index 51e4373..eb1fb4e 100644 --- a/lib/widget/async_actions/async_action_button.dart +++ b/lib/widget/async_actions/async_action_button.dart @@ -32,20 +32,12 @@ class AsyncActionButton extends StatelessWidget { onError: onError, onSuccess: onSuccess, builder: (context, busy, handler) { - final spinner = AppProgressIndicator.small( - color: Theme.of(context).colorScheme.onPrimary, - ); - final content = busy - ? Row( - mainAxisSize: MainAxisSize.min, - children: [spinner, const SizedBox(width: 8), child], + final lead = busy + ? AppProgressIndicator.small( + color: Theme.of(context).colorScheme.onPrimary, ) - : (icon != null - ? Row( - mainAxisSize: MainAxisSize.min, - children: [Icon(icon), const SizedBox(width: 8), child], - ) - : child); + : (icon == null ? null : Icon(icon)); + final content = lead == null ? child : _leadingLabel(lead, child); final button = ElevatedButton( onPressed: handler, style: style, diff --git a/lib/widget/async_actions/async_action_controller.dart b/lib/widget/async_actions/async_action_controller.dart index 0d27c65..a439096 100644 --- a/lib/widget/async_actions/async_action_controller.dart +++ b/lib/widget/async_actions/async_action_controller.dart @@ -8,6 +8,11 @@ typedef AsyncErrorBuilder = String Function(Object error); TextStyle _asyncErrorTextStyle(BuildContext context) => TextStyle(color: Theme.of(context).colorScheme.error, fontSize: 13); +Widget _leadingLabel(Widget leading, Widget label) => Row( + mainAxisSize: MainAxisSize.min, + children: [leading, const SizedBox(width: 8), label], +); + /// Wraps [action] with a try/catch that pops up an [InfoDialog] on failure /// (using [errorBuilder] or the default error mapper). Returns `true` on /// success, `false` on caught failure. @@ -23,14 +28,7 @@ Future runWithErrorDialog( } catch (e) { Haptics.error(); if (!context.mounted) return false; - final message = errorBuilder != null - ? errorBuilder(e) - : errorToUserMessage(e); - final details = errorToTechnicalDetails(e); - final body = details != null && details != message - ? '$message\n\n$details' - : message; - InfoDialog.show(context, body, copyable: true, title: 'Fehler'); + InfoDialog.showError(context, e, message: errorBuilder?.call(e)); return false; } } @@ -55,14 +53,13 @@ class AsyncActionController extends ChangeNotifier { notifyListeners(); try { await action(); - _busy = false; - notifyListeners(); return true; } catch (e) { - _busy = false; _error = errorBuilder != null ? errorBuilder(e) : errorToUserMessage(e); - notifyListeners(); return false; + } finally { + _busy = false; + notifyListeners(); } } diff --git a/lib/widget/async_actions/async_dialog_action.dart b/lib/widget/async_actions/async_dialog_action.dart index 50768ab..d076408 100644 --- a/lib/widget/async_actions/async_dialog_action.dart +++ b/lib/widget/async_actions/async_dialog_action.dart @@ -68,15 +68,11 @@ class _AsyncDialogActionState extends State { } }, child: _controller.busy - ? Row( - mainAxisSize: MainAxisSize.min, - children: [ - AppProgressIndicator.small( - color: Theme.of(context).colorScheme.primary, - ), - const SizedBox(width: 8), - Text(widget.confirmLabel), - ], + ? _leadingLabel( + AppProgressIndicator.small( + color: Theme.of(context).colorScheme.primary, + ), + Text(widget.confirmLabel), ) : Text(widget.confirmLabel), ), diff --git a/lib/widget/async_actions/async_mixin.dart b/lib/widget/async_actions/async_mixin.dart index 2bb4da1..075db21 100644 --- a/lib/widget/async_actions/async_mixin.dart +++ b/lib/widget/async_actions/async_mixin.dart @@ -23,15 +23,13 @@ class _AsyncMixin extends StatefulWidget { } class _AsyncMixinState extends State<_AsyncMixin> { - late final AsyncActionController _internal; - AsyncActionController get _controller => widget.controller ?? _internal; + AsyncActionController? _own; + AsyncActionController get _controller => + widget.controller ?? (_own ??= AsyncActionController()); @override void initState() { super.initState(); - if (widget.controller == null) { - _internal = AsyncActionController(); - } _controller.addListener(_onControllerChange); } @@ -39,7 +37,7 @@ class _AsyncMixinState extends State<_AsyncMixin> { void didUpdateWidget(covariant _AsyncMixin oldWidget) { super.didUpdateWidget(oldWidget); if (oldWidget.controller != widget.controller) { - (oldWidget.controller ?? _internal).removeListener(_onControllerChange); + (oldWidget.controller ?? _own)?.removeListener(_onControllerChange); _controller.addListener(_onControllerChange); } } @@ -47,9 +45,7 @@ class _AsyncMixinState extends State<_AsyncMixin> { @override void dispose() { _controller.removeListener(_onControllerChange); - if (widget.controller == null) { - _internal.dispose(); - } + _own?.dispose(); super.dispose(); } @@ -76,11 +72,11 @@ class _AsyncMixinState extends State<_AsyncMixin> { @override Widget build(BuildContext context) { - final handler = widget.onPressed == null ? null : _trigger; + final busy = _controller.busy; return widget.builder( context, - _controller.busy, - _controller.busy ? null : handler, + busy, + busy || widget.onPressed == null ? null : _trigger, ); } } diff --git a/lib/widget/async_actions/async_text_button.dart b/lib/widget/async_actions/async_text_button.dart index 725ac79..95a0f95 100644 --- a/lib/widget/async_actions/async_text_button.dart +++ b/lib/widget/async_actions/async_text_button.dart @@ -29,15 +29,11 @@ class AsyncTextButton extends StatelessWidget { onSuccess: onSuccess, builder: (context, busy, handler) { final content = busy - ? Row( - mainAxisSize: MainAxisSize.min, - children: [ - AppProgressIndicator.small( - color: Theme.of(context).colorScheme.primary, - ), - const SizedBox(width: 8), - child, - ], + ? _leadingLabel( + AppProgressIndicator.small( + color: Theme.of(context).colorScheme.primary, + ), + child, ) : child; final button = TextButton(onPressed: handler, child: content); diff --git a/lib/widget/avatar_actions_sheet.dart b/lib/widget/avatar_actions_sheet.dart index 901975e..4728dda 100644 --- a/lib/widget/avatar_actions_sheet.dart +++ b/lib/widget/avatar_actions_sheet.dart @@ -1,7 +1,6 @@ import 'dart:typed_data'; import 'package:flutter/material.dart'; -import 'package:image_picker/image_picker.dart'; import '../routing/app_routes.dart'; import 'details_bottom_sheet.dart'; @@ -36,31 +35,11 @@ Future showAvatarActionsSheet( await showDetailsBottomSheet( context, children: (sheetContext) => [ - ListTile( - leading: const Icon(Icons.photo_library_outlined), - title: const Text('Aus Galerie wählen'), - onTap: () async { - final bytes = await _pickAndCrop( - sheetContext, - () => FilePick.singleGalleryPick(sheetContext, forCrop: true), - ); - if (bytes == null || !sheetContext.mounted) return; - result = AvatarUploadResult(bytes); - Navigator.of(sheetContext).pop(); - }, - ), - ListTile( - leading: const Icon(Icons.photo_camera_outlined), - title: const Text('Foto aufnehmen'), - onTap: () async { - final bytes = await _pickAndCrop( - sheetContext, - () => FilePick.cameraPick(sheetContext, forCrop: true), - ); - if (bytes == null || !sheetContext.mounted) return; - result = AvatarUploadResult(bytes); - Navigator.of(sheetContext).pop(); - }, + ...imageSourceTiles( + sheetContext, + transform: (bytes) => + AppRoutes.openAvatarCrop(sheetContext, imageBytes: bytes), + onPicked: (bytes) => result = AvatarUploadResult(bytes), ), if (allowRemove) ...[ const Divider(), @@ -83,14 +62,3 @@ Future showAvatarActionsSheet( ); return result; } - -Future _pickAndCrop( - BuildContext context, - Future Function() pick, -) async { - final picked = await pick(); - if (picked == null) return null; - final bytes = await picked.readAsBytes(); - if (!context.mounted) return null; - return AppRoutes.openAvatarCrop(context, imageBytes: bytes); -} diff --git a/lib/widget/avatar_disk_cache.dart b/lib/widget/avatar_disk_cache.dart index 2d038f4..fa0ecbe 100644 --- a/lib/widget/avatar_disk_cache.dart +++ b/lib/widget/avatar_disk_cache.dart @@ -66,13 +66,15 @@ class AvatarDiskCache { return isGroup ? 'g_$hex' : 'u_${hex}_$size'; } - static String _hex(String value) { - final buffer = StringBuffer(); - for (final b in utf8.encode(value)) { - buffer.write(b.toRadixString(16).padLeft(2, '0')); - } - return buffer.toString(); - } + static String _hex(String value) => + utf8.encode(value).map((b) => b.toRadixString(16).padLeft(2, '0')).join(); + + File _file( + String dirPath, { + required String id, + required bool isGroup, + required int size, + }) => File('$dirPath/${fileName(id: id, isGroup: isGroup, size: size)}'); /// Synchronous read for warm sessions (cache directory already resolved). /// Returns null when the directory isn't known yet — the caller falls back @@ -86,9 +88,7 @@ class AvatarDiskCache { final path = _dirPath; if (path == null) return null; try { - final file = File( - '$path/${fileName(id: id, isGroup: isGroup, size: size)}', - ); + final file = _file(path, id: id, isGroup: isGroup, size: size); if (!file.existsSync()) return null; final bytes = file.readAsBytesSync(); return bytes.isEmpty ? null : bytes; @@ -104,9 +104,7 @@ class AvatarDiskCache { }) async { try { final dir = await _directory(); - final file = File( - '${dir.path}/${fileName(id: id, isGroup: isGroup, size: size)}', - ); + final file = _file(dir.path, id: id, isGroup: isGroup, size: size); if (!file.existsSync()) return null; final bytes = await file.readAsBytes(); return bytes.isEmpty ? null : bytes; @@ -123,10 +121,12 @@ class AvatarDiskCache { }) async { try { final dir = await _directory(); - final file = File( - '${dir.path}/${fileName(id: id, isGroup: isGroup, size: size)}', - ); - await file.writeAsBytes(bytes, flush: true); + await _file( + dir.path, + id: id, + isGroup: isGroup, + size: size, + ).writeAsBytes(bytes); if (!_pruned) { _pruned = true; unawaited(_prune(dir)); @@ -142,7 +142,7 @@ class AvatarDiskCache { try { final dir = await _directory(); if (isGroup) { - final file = File('${dir.path}/${fileName(id: id, isGroup: true, size: 0)}'); + final file = _file(dir.path, id: id, isGroup: true, size: 0); if (file.existsSync()) await file.delete(); return; } diff --git a/lib/widget/chat_background_picker_sheet.dart b/lib/widget/chat_background_picker_sheet.dart index cbae7d0..c90802d 100644 --- a/lib/widget/chat_background_picker_sheet.dart +++ b/lib/widget/chat_background_picker_sheet.dart @@ -1,7 +1,6 @@ import 'dart:typed_data'; import 'package:flutter/material.dart'; -import 'package:image_picker/image_picker.dart'; import '../routing/app_routes.dart'; import 'details_bottom_sheet.dart'; @@ -16,32 +15,8 @@ Future showChatBackgroundPickerSheet(BuildContext context) async { Uint8List? result; await showDetailsBottomSheet( context, - children: (sheetContext) => [ - ListTile( - leading: const Icon(Icons.photo_library_outlined), - title: const Text('Aus Galerie wählen'), - onTap: () async { - final bytes = await _pickRaw( - () => FilePick.singleGalleryPick(sheetContext, forCrop: true), - ); - if (bytes == null || !sheetContext.mounted) return; - result = bytes; - Navigator.of(sheetContext).pop(); - }, - ), - ListTile( - leading: const Icon(Icons.photo_camera_outlined), - title: const Text('Foto aufnehmen'), - onTap: () async { - final bytes = await _pickRaw( - () => FilePick.cameraPick(sheetContext, forCrop: true), - ); - if (bytes == null || !sheetContext.mounted) return; - result = bytes; - Navigator.of(sheetContext).pop(); - }, - ), - ], + children: (sheetContext) => + imageSourceTiles(sheetContext, onPicked: (bytes) => result = bytes), ); return result; } @@ -53,9 +28,3 @@ Future cropChatBackgroundImage( BuildContext context, Uint8List bytes, ) => AppRoutes.openAvatarCrop(context, imageBytes: bytes, aspectRatio: null); - -Future _pickRaw(Future Function() pick) async { - final picked = await pick(); - if (picked == null) return null; - return picked.readAsBytes(); -} diff --git a/lib/widget/confirm_dialog.dart b/lib/widget/confirm_dialog.dart index 3bf3105..3e83421 100644 --- a/lib/widget/confirm_dialog.dart +++ b/lib/widget/confirm_dialog.dart @@ -67,15 +67,10 @@ class ConfirmDialog extends StatelessWidget { ], ); - static void openBrowser(BuildContext context, String url) { - showDialog( - context: context, - builder: (context) => ConfirmDialog( - title: 'Link öffnen', - content: 'Möchtest du den folgenden Link öffnen?\n$url', - confirmButton: 'Öffnen', - onConfirm: () => UrlOpener.openUrl(url), - ), - ); - } + static void openBrowser(BuildContext context, String url) => ConfirmDialog( + title: 'Link öffnen', + content: 'Möchtest du den folgenden Link öffnen?\n$url', + confirmButton: 'Öffnen', + onConfirm: () => UrlOpener.openUrl(url), + ).asDialog(context); } diff --git a/lib/widget/downloads/download_tray.dart b/lib/widget/downloads/download_tray.dart index 040a956..71e654e 100644 --- a/lib/widget/downloads/download_tray.dart +++ b/lib/widget/downloads/download_tray.dart @@ -5,7 +5,6 @@ import 'package:flutter/material.dart'; import '../../routing/app_routes.dart'; import '../../utils/downloads/download_job.dart'; import '../../utils/downloads/download_manager.dart'; -import '../../utils/haptics.dart'; import 'downloads_sheet.dart'; import 'stale_download_guard.dart'; @@ -204,10 +203,7 @@ class _DownloadTrayHostState extends State _manager.markOpened(job); return; } - if (!ensureDownloadStillExists(ctx, job)) return; - _manager.markOpened(job); - Haptics.success(); - AppRoutes.openFileViewer(ctx, path, remoteFile: job.remoteFile); + openFinishedDownload(ctx, job, path); } /// Distance from the bottom to float the chip. Anchored bottom-LEFT (FABs are diff --git a/lib/widget/downloads/downloads_sheet.dart b/lib/widget/downloads/downloads_sheet.dart index 847bc17..31b61a0 100644 --- a/lib/widget/downloads/downloads_sheet.dart +++ b/lib/widget/downloads/downloads_sheet.dart @@ -2,10 +2,8 @@ import 'dart:async'; import 'package:flutter/material.dart'; -import '../../routing/app_routes.dart'; import '../../utils/downloads/download_job.dart'; import '../../utils/downloads/download_manager.dart'; -import '../../utils/haptics.dart'; import '../../view/pages/files/data/file_type_icon.dart'; import '../centered_leading.dart'; import '../details_bottom_sheet.dart'; @@ -90,14 +88,7 @@ class _DownloadsListState extends State<_DownloadsList> { // Pop the sheet first so a stale-file dialog lands on the underlying // screen instead of an emptied sheet. Navigator.of(widget.sheetContext).pop(); - if (!ensureDownloadStillExists(widget.rootContext, job)) return; - Haptics.success(); - DownloadManager.instance.markOpened(job); - AppRoutes.openFileViewer( - widget.rootContext, - path, - remoteFile: job.remoteFile, - ); + openFinishedDownload(widget.rootContext, job, path); } } @@ -140,8 +131,7 @@ class _DownloadRow extends StatelessWidget { DownloadInProgress() => IconButton( icon: const Icon(Icons.close), tooltip: 'Abbrechen', - onPressed: () => - unawaited(DownloadManager.instance.cancel(job)), + onPressed: () => unawaited(DownloadManager.instance.cancel(job)), ), DownloadFailed() => IconButton( icon: const Icon(Icons.refresh), @@ -167,7 +157,10 @@ class _DownloadRow extends StatelessWidget { alignment: Alignment.centerRight, padding: const EdgeInsets.only(right: 24), color: theme.colorScheme.errorContainer, - child: Icon(Icons.delete_outline, color: theme.colorScheme.onErrorContainer), + child: Icon( + Icons.delete_outline, + color: theme.colorScheme.onErrorContainer, + ), ), onDismissed: (_) => DownloadManager.instance.dismiss(job), child: tile, diff --git a/lib/widget/downloads/stale_download_guard.dart b/lib/widget/downloads/stale_download_guard.dart index 457d3b2..c484458 100644 --- a/lib/widget/downloads/stale_download_guard.dart +++ b/lib/widget/downloads/stale_download_guard.dart @@ -3,8 +3,10 @@ import 'dart:io'; import 'package:flutter/material.dart'; +import '../../routing/app_routes.dart'; import '../../utils/downloads/download_job.dart'; import '../../utils/downloads/download_manager.dart'; +import '../../utils/haptics.dart'; import '../confirm_dialog.dart'; import '../info_dialog.dart'; @@ -21,20 +23,45 @@ bool ensureDownloadStillExists(BuildContext context, DownloadJob job) { if (path == null || File(path).existsSync()) return true; DownloadManager.instance.markOpened(job); - if (job.remotePath.isNotEmpty) { - ConfirmDialog( - title: 'Datei nicht mehr verfügbar', - content: - 'Die heruntergeladene Datei wurde vom System aus dem Zwischenspeicher entfernt.\nErneut herunterladen?', - confirmButton: 'Herunterladen', - onConfirm: () => unawaited(DownloadManager.instance.retry(job)), - ).asDialog(context); - } else { - InfoDialog.show( - context, - 'Die heruntergeladene Datei wurde vom System aus dem Zwischenspeicher entfernt. Bitte lade sie erneut herunter.', - title: 'Datei nicht mehr verfügbar', - ); - } + showFileGoneDialog( + context, + subject: 'Die heruntergeladene Datei', + onRedownload: job.remotePath.isEmpty + ? null + : () => unawaited(DownloadManager.instance.retry(job)), + ); return false; } + +/// Opens a finished download in the viewer, unless its file vanished. +void openFinishedDownload(BuildContext context, DownloadJob job, String path) { + if (!ensureDownloadStillExists(context, job)) return; + DownloadManager.instance.markOpened(job); + Haptics.success(); + AppRoutes.openFileViewer(context, path, remoteFile: job.remoteFile); +} + +/// Tells the user that the system cleared a cached file ([subject], e.g. +/// "Die Datei") and offers a re-download when [onRedownload] is given. +void showFileGoneDialog( + BuildContext context, { + required String subject, + VoidCallback? onRedownload, +}) { + const title = 'Datei nicht mehr verfügbar'; + final gone = '$subject wurde vom System aus dem Zwischenspeicher entfernt.'; + if (onRedownload == null) { + InfoDialog.show( + context, + '$gone Bitte lade sie erneut herunter.', + title: title, + ); + return; + } + ConfirmDialog( + title: title, + content: '$gone\nErneut herunterladen?', + confirmButton: 'Herunterladen', + onConfirm: onRedownload, + ).asDialog(context); +} diff --git a/lib/widget/file_pick.dart b/lib/widget/file_pick.dart index 7f2578e..60aff87 100644 --- a/lib/widget/file_pick.dart +++ b/lib/widget/file_pick.dart @@ -4,7 +4,6 @@ import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:image_picker/image_picker.dart'; -import '../api/errors/error_mapper.dart'; import '../api/errors/permission_exception.dart'; import 'confirm_dialog.dart'; import 'info_dialog.dart'; @@ -86,12 +85,45 @@ class FilePick { onConfirm: AppSettings.openAppSettings, ).asDialog(context); - static void _showErrorDialog(BuildContext context, Object error) { - final message = errorToUserMessage(error); - final details = errorToTechnicalDetails(error); - final body = details != null && details != message - ? '$message\n\n$details' - : message; - InfoDialog.show(context, body, copyable: true, title: 'Fehler'); - } + static void _showErrorDialog(BuildContext context, Object error) => + InfoDialog.showError(context, error); +} + +/// "Aus Galerie wählen" / "Foto aufnehmen" tiles for an image-source sheet. +/// The optional [transform] (e.g. a cropper) runs with the sheet still +/// mounted; the sheet pops only once [onPicked] received the final bytes. +List imageSourceTiles( + BuildContext sheetContext, { + required void Function(Uint8List bytes) onPicked, + Future Function(Uint8List bytes)? transform, +}) { + Widget tile(IconData icon, String title, Future Function() pick) => + ListTile( + leading: Icon(icon), + title: Text(title), + onTap: () async { + final picked = await pick(); + if (picked == null) return; + Uint8List? bytes = await picked.readAsBytes(); + if (transform != null) { + if (!sheetContext.mounted) return; + bytes = await transform(bytes); + } + if (bytes == null || !sheetContext.mounted) return; + onPicked(bytes); + Navigator.of(sheetContext).pop(); + }, + ); + return [ + tile( + Icons.photo_library_outlined, + 'Aus Galerie wählen', + () => FilePick.singleGalleryPick(sheetContext, forCrop: true), + ), + tile( + Icons.photo_camera_outlined, + 'Foto aufnehmen', + () => FilePick.cameraPick(sheetContext, forCrop: true), + ), + ]; } diff --git a/lib/widget/file_viewer.dart b/lib/widget/file_viewer.dart index 17a0b2d..266d626 100644 --- a/lib/widget/file_viewer.dart +++ b/lib/widget/file_viewer.dart @@ -20,7 +20,7 @@ import '../utils/screen_bound_image.dart'; import 'app_progress_indicator.dart'; import 'async_action_button.dart'; import 'centered_leading.dart'; -import 'confirm_dialog.dart'; +import 'downloads/stale_download_guard.dart'; import 'file_viewer/code_line.dart'; import 'file_viewer/deferred_pdf_viewer.dart'; import 'file_viewer/file_kind.dart'; @@ -54,15 +54,20 @@ class _FileViewerState extends State { Future<_TextPayload>? _textPayload; final PhotoViewController photoViewController = PhotoViewController(); - late SettingsCubit settings = context.read(); late bool openExternal; Future? _fileKind; + String get _fileName => widget.path.split('/').last; + @override void initState() { super.initState(); openExternal = - settings.val().fileViewSettings.alwaysOpenExternally || + context + .read() + .val() + .fileViewSettings + .alwaysOpenExternally || widget.openExternal; if (openExternal) { WidgetsBinding.instance.addPostFrameCallback( @@ -93,31 +98,23 @@ class _FileViewerState extends State { bool _ensureLocalFile() { if (File(widget.path).existsSync()) return true; final remote = widget.remoteFile; - if (remote == null) { - InfoDialog.show( - context, - 'Die Datei wurde vom System aus dem Zwischenspeicher entfernt. Bitte lade sie erneut herunter.', - title: 'Datei nicht mehr verfügbar', - ); - return false; - } - ConfirmDialog( - title: 'Datei nicht mehr verfügbar', - content: - 'Die Datei wurde vom System aus dem Zwischenspeicher entfernt.\nErneut herunterladen?', - confirmButton: 'Herunterladen', - onConfirm: () { - // Pop the viewer before starting so the fresh download auto-opens. - Navigator.of(context).pop(); - unawaited( - DownloadManager.instance.start( - remotePath: remote.path, - name: remote.name, - remoteFile: remote, - ), - ); - }, - ).asDialog(context); + showFileGoneDialog( + context, + subject: 'Die Datei', + onRedownload: remote == null + ? null + : () { + // Pop the viewer before starting so the fresh download auto-opens. + Navigator.of(context).pop(); + unawaited( + DownloadManager.instance.start( + remotePath: remote.path, + name: remote.name, + remoteFile: remote, + ), + ); + }, + ); return false; } @@ -172,7 +169,7 @@ class _FileViewerState extends State { } final bytes = await source.readAsBytes(); final saved = await FilePicker.saveFile( - fileName: widget.path.split('/').last, + fileName: _fileName, bytes: bytes, ); if (!mounted) return; @@ -226,7 +223,7 @@ class _FileViewerState extends State { List actions = const [], bool showActionsMenu = true, }) => AppBar( - title: Text(widget.path.split('/').last), + title: Text(_fileName), actions: [ ...actions, if (showActionsMenu) @@ -253,7 +250,7 @@ class _FileViewerState extends State { Widget build(BuildContext context) { if (openExternal) { return Scaffold( - appBar: AppBar(title: Text(widget.path.split('/').last)), + appBar: _appbar(showActionsMenu: false), body: const Center(child: AppProgressIndicator.large()), ); } @@ -347,21 +344,17 @@ class _FileViewerState extends State { Widget _buildAudioView() => Scaffold( appBar: _appbar(), - body: MediaPlayer( - path: widget.path, - isAudio: true, - filename: widget.path.split('/').last, - ), + body: MediaPlayer(path: widget.path, isAudio: true, filename: _fileName), ); Widget _buildTextView() => Scaffold( appBar: _appbar(), body: FutureBuilder<_TextPayload>( // Cached: a future created in build re-read the file on every rebuild. - future: _textPayload ??= compute( - _loadTextPayload, - (widget.path, _textViewMaxBytes), - ), + future: _textPayload ??= compute(_loadTextPayload, ( + widget.path, + _textViewMaxBytes, + )), builder: (context, snapshot) { if (!snapshot.hasData) { return const Center(child: AppProgressIndicator.large()); @@ -428,7 +421,7 @@ class _FileViewerState extends State { UnknownPreviewBlock(remoteFile: widget.remoteFile), const SizedBox(height: 16), Text( - widget.path.split('/').last, + _fileName, style: theme.textTheme.titleSmall?.copyWith( fontWeight: FontWeight.w600, ), diff --git a/lib/widget/info_dialog.dart b/lib/widget/info_dialog.dart index 22c4624..c034355 100644 --- a/lib/widget/info_dialog.dart +++ b/lib/widget/info_dialog.dart @@ -1,5 +1,6 @@ import 'package:flutter/material.dart'; +import '../api/errors/error_mapper.dart'; import '../utils/clipboard_helper.dart'; class InfoDialog { @@ -40,4 +41,15 @@ class InfoDialog { }, ); } + + /// Error dialog with the user-facing [message] (default: mapped from + /// [error]) plus the technical details when they add information. + static void showError(BuildContext context, Object error, {String? message}) { + final text = message ?? errorToUserMessage(error); + final details = errorToTechnicalDetails(error); + final body = details != null && details != text + ? '$text\n\n$details' + : text; + show(context, body, copyable: true, title: 'Fehler'); + } } diff --git a/lib/widget/list_view_util.dart b/lib/widget/list_view_util.dart index efa8ee8..71dece8 100644 --- a/lib/widget/list_view_util.dart +++ b/lib/widget/list_view_util.dart @@ -4,7 +4,6 @@ class ListViewUtil { static ListView fromList(List? items, Widget Function(T item) map) => ListView.builder( itemCount: items?.length ?? 0, - itemBuilder: (context, index) => - items != null ? map(items[index]) : null, + itemBuilder: (context, index) => map(items![index]), ); } diff --git a/lib/widget/prosemirror/pm_image_view.dart b/lib/widget/prosemirror/pm_image_view.dart index 1a347f4..f9f9e73 100644 --- a/lib/widget/prosemirror/pm_image_view.dart +++ b/lib/widget/prosemirror/pm_image_view.dart @@ -123,19 +123,14 @@ class PmImageView extends StatelessWidget { double? _resolveWidth(String? width, double available) { if (width == null) return null; - final match = RegExp(r'^(\d+(?:\.\d+)?)(px|%|em|rem)$').firstMatch(width); - if (match == null) return null; - final value = double.parse(match.group(1)!); - final double resolved; - switch (match.group(2)) { - case '%': - resolved = available * value / 100; - case 'em': - case 'rem': - resolved = value * 16; - default: - resolved = value; - } + final length = parsePmCssLength(width); + if (length == null) return null; + final value = length.value; + final resolved = switch (length.unit) { + '%' => available * value / 100, + 'em' || 'rem' => value * 16, + _ => value, + }; return resolved.clamp(1, available); } } diff --git a/lib/widget/prosemirror/pm_node.dart b/lib/widget/prosemirror/pm_node.dart index a2fcfbb..0febc66 100644 --- a/lib/widget/prosemirror/pm_node.dart +++ b/lib/widget/prosemirror/pm_node.dart @@ -325,3 +325,12 @@ class PmUnknown extends PmNode { const PmUnknown({required this.rawType, this.children = const []}); } + +final RegExp _cssLength = RegExp(r'^(\d+(?:\.\d+)?)(px|%|em|rem)$'); + +/// Splits a CSS length like `12px` / `1.5em` / `50%` into value and unit. +({double value, String unit})? parsePmCssLength(String raw) { + final match = _cssLength.firstMatch(raw); + if (match == null) return null; + return (value: double.parse(match.group(1)!), unit: match.group(2)!); +} diff --git a/lib/widget/prosemirror/pm_rich_text.dart b/lib/widget/prosemirror/pm_rich_text.dart index b989b7f..d44cd34 100644 --- a/lib/widget/prosemirror/pm_rich_text.dart +++ b/lib/widget/prosemirror/pm_rich_text.dart @@ -152,22 +152,15 @@ class _PmRichTextState extends State { double? _fontSize(dynamic raw, double base) { if (raw is! String) return null; - final match = RegExp(r'^(\d+(?:\.\d+)?)(px|rem|em|%)$').firstMatch(raw); - if (match == null) return null; - final value = double.parse(match.group(1)!); - final double size; - switch (match.group(2)) { - case 'px': - size = value; - case 'rem': - size = value * 16; - case 'em': - size = base * value; - case '%': - size = base * value / 100; - default: - return null; - } + final length = parsePmCssLength(raw); + if (length == null) return null; + final value = length.value; + final size = switch (length.unit) { + 'px' => value, + 'rem' => value * 16, + 'em' => base * value, + _ => base * value / 100, + }; return size.clamp(8, 72); } diff --git a/lib/widget/prosemirror/pm_table_view.dart b/lib/widget/prosemirror/pm_table_view.dart index ddc91b1..c31b63b 100644 --- a/lib/widget/prosemirror/pm_table_view.dart +++ b/lib/widget/prosemirror/pm_table_view.dart @@ -195,7 +195,7 @@ class _PmTableViewState extends State { required bool left, required bool visible, }) { - final dark = Theme.of(context).brightness == Brightness.dark; + final dark = AppTheme.isDarkMode(context); final color = Colors.black.withValues(alpha: dark ? 0.28 : 0.12); return Positioned( top: 0, diff --git a/lib/widget/user_avatar.dart b/lib/widget/user_avatar.dart index f9f0f29..3bba5d2 100644 --- a/lib/widget/user_avatar.dart +++ b/lib/widget/user_avatar.dart @@ -1,6 +1,7 @@ import 'dart:async'; import 'dart:collection'; import 'dart:convert'; +import 'dart:math'; import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; @@ -76,13 +77,16 @@ final Map> _pendingAvatars = {}; final ValueNotifier _avatarCacheGeneration = ValueNotifier(0); String avatarUrl({required String id, required bool isGroup, int size = 512}) { - final host = EndpointData().nextcloud().full(); if (isGroup) { + final host = EndpointData().nextcloud().full(); return 'https://$host/ocs/v2.php/apps/spreed/api/v1/room/$id/avatar'; } - return 'https://$host/avatar/$id/$size'; + return '${_userAvatarUrlPrefix(id)}$size'; } +String _userAvatarUrlPrefix(String id) => + 'https://${EndpointData().nextcloud().full()}/avatar/$id/'; + /// Resolves an avatar through the shared caches: in-memory first, then disk, /// then the network — the fetch being deduped across every concurrent caller /// and written back to both caches. @@ -109,26 +113,32 @@ Future loadAvatarPayload({ return payload; } - final payload = await _fetchDeduped(url); - _commitAvatar(url, id: id, isGroup: isGroup, size: size, payload: payload); - return payload; + return _fetchDeduped(url, id: id, isGroup: isGroup, size: size); } -/// Shares one in-flight request per URL across all callers. -Future _fetchDeduped(String url) => - _pendingAvatars.putIfAbsent(url, () { - final future = _fetchAvatarPayload(url); - // Cleanup hangs off an error-neutralised copy: whenComplete on `future` - // itself returns a second future that forwards the error unawaited. - unawaited( - future.then((_) {}, onError: (_) {}).whenComplete(() { - if (identical(_pendingAvatars[url], future)) { - _pendingAvatars.remove(url); - } - }), - ); - return future; - }); +/// Shares one in-flight request per URL across all callers; the result is +/// committed to the caches once, not once per waiting caller. +Future _fetchDeduped( + String url, { + required String id, + required bool isGroup, + required int size, +}) => _pendingAvatars.putIfAbsent(url, () { + final future = _fetchAvatarPayload(url).then((payload) { + _commitAvatar(url, id: id, isGroup: isGroup, size: size, payload: payload); + return payload; + }); + // Cleanup hangs off an error-neutralised copy: whenComplete on `future` + // itself returns a second future that forwards the error unawaited. + unawaited( + future.then((_) {}, onError: (_) {}).whenComplete(() { + if (identical(_pendingAvatars[url], future)) { + _pendingAvatars.remove(url); + } + }), + ); + return future; +}); /// Persists a resolved result to the in-memory and disk caches. void _commitAvatar( @@ -189,10 +199,7 @@ AvatarPayload _payloadFromBytes(Uint8List bytes) => bool _looksLikeSvg(Uint8List bytes) { final head = utf8 - .decode( - bytes.sublist(0, bytes.length < 256 ? bytes.length : 256), - allowMalformed: true, - ) + .decode(bytes.sublist(0, min(bytes.length, 256)), allowMalformed: true) .trimLeft(); return head.startsWith(' url.startsWith(prefix)); _pendingAvatars.removeWhere((url, _) => url.startsWith(prefix)); unawaited(AvatarDiskCache.instance.evict(id: id, isGroup: false)); @@ -285,14 +291,15 @@ class _UserAvatarState extends State { int _resolvedRequestSize() => widget.requestSize ?? (widget.size * 4).clamp(64, 1024); - String _url() => avatarUrl( + String _url([int? size]) => avatarUrl( id: widget.id, isGroup: widget.isGroup, - size: _resolvedRequestSize(), + size: size ?? _resolvedRequestSize(), ); void _attach() { - final url = _url(); + final size = _resolvedRequestSize(); + final url = _url(size); final cached = _readAvatarCache(url); if (cached != null) { _payload = cached.payload; @@ -302,7 +309,6 @@ class _UserAvatarState extends State { // the widget may have been recycled onto a different id by then. final id = widget.id; final isGroup = widget.isGroup; - final size = _resolvedRequestSize(); // Persistent disk cache: on a warm session (cache directory already known) // this hits synchronously, so a cold app start paints the last-known @@ -348,14 +354,13 @@ class _UserAvatarState extends State { AvatarPayload? fresh; try { - fresh = await _fetchDeduped(url); + fresh = await _fetchDeduped(url, id: id, isGroup: isGroup, size: size); } on Object { // Transient failure (offline, 5xx). Keep showing the cached picture; the // next mount retries. Deliberately no null-cache so we don't mask it. return; } - _commitAvatar(url, id: id, isGroup: isGroup, size: size, payload: fresh); if (!mounted || _url() != url) return; if (fresh == null) { // HTTP 404 — the avatar was removed server-side. Fall back to the icon. diff --git a/lib/widget_data/widget_data_mapper.dart b/lib/widget_data/widget_data_mapper.dart index 79a37dd..b1ef59c 100644 --- a/lib/widget_data/widget_data_mapper.dart +++ b/lib/widget_data/widget_data_mapper.dart @@ -53,6 +53,42 @@ class WidgetDataMapper { /// native renderers can roll the view forward without fresh data. static const int weekWindowDays = 14; + /// Day and week payload from the same inputs. + static ({WidgetTimetableData day, WidgetTimetableData week}) buildSnapshot({ + required DateTime now, + required Iterable lessons, + required TimetableGetSubjectsResponse? subjects, + required TimetableGetRoomsResponse? rooms, + required TimetableGetHolidaysResponse? holidays, + TimetableGetTimegridResponse? timegrid, + GetCustomTimetableEventResponse? customEvents, + bool connectDoubleLessons = true, + bool showClassInsteadOfTeacher = false, + }) => ( + day: buildDayData( + now: now, + lessons: lessons, + subjects: subjects, + rooms: rooms, + holidays: holidays, + timegrid: timegrid, + customEvents: customEvents, + connectDoubleLessons: connectDoubleLessons, + showClassInsteadOfTeacher: showClassInsteadOfTeacher, + ), + week: buildWeekData( + now: now, + lessons: lessons, + subjects: subjects, + rooms: rooms, + holidays: holidays, + timegrid: timegrid, + customEvents: customEvents, + connectDoubleLessons: connectDoubleLessons, + showClassInsteadOfTeacher: showClassInsteadOfTeacher, + ), + ); + static WidgetTimetableData buildDayData({ required DateTime now, required Iterable lessons, @@ -68,24 +104,23 @@ class WidgetDataMapper { final holiday = _findHoliday(anchor, holidays); final dayStart = anchor; final dayEnd = anchor.add(const Duration(days: 1)); - final dayLessons = lessons.where((l) => _onSameDay(l, anchor)).toList(); + final dayLessons = lessons.where((l) => l.date.isSameDay(anchor)).toList(); final source = connectDoubleLessons ? LessonMerger.merge(dayLessons) : dayLessons; - final mapped = [ - ..._mapAll( + return WidgetTimetableData( + fetchedAt: now, + anchorDate: anchor, + lessons: _mapWindow( source, now, subjects, rooms, + customEvents, + dayStart, + dayEnd, showClassInsteadOfTeacher: showClassInsteadOfTeacher, ), - ..._expandCustomEvents(customEvents, dayStart, dayEnd), - ]..sort((a, b) => a.start.compareTo(b.start)); - return WidgetTimetableData( - fetchedAt: now, - anchorDate: anchor, - lessons: _resolveCollisions(mapped), periods: _resolvePeriods(timegrid), isHoliday: holiday != null, holidayName: holiday?.longName, @@ -118,16 +153,6 @@ class WidgetDataMapper { final source = connectDoubleLessons ? _mergePerDay(weekLessons) : weekLessons; - final mapped = [ - ..._mapAll( - source, - now, - subjects, - rooms, - showClassInsteadOfTeacher: showClassInsteadOfTeacher, - ), - ..._expandCustomEvents(customEvents, windowStart, endExclusive), - ]..sort((a, b) => a.start.compareTo(b.start)); final days = [ for (var i = 0; i < weekWindowDays; i++) _dayInfo(windowStart.addDays(i), holidays), @@ -141,7 +166,16 @@ class WidgetDataMapper { return WidgetTimetableData( fetchedAt: now, anchorDate: anchor, - lessons: _resolveCollisions(mapped), + lessons: _mapWindow( + source, + now, + subjects, + rooms, + customEvents, + windowStart, + endExclusive, + showClassInsteadOfTeacher: showClassInsteadOfTeacher, + ), periods: _resolvePeriods(timegrid), isHoliday: anchorInfo.isHoliday, holidayName: anchorInfo.holidayName, @@ -300,28 +334,38 @@ class WidgetDataMapper { return [for (final group in byDay.values) ...LessonMerger.merge(group)]; } - static Iterable _mapAll( + /// Lessons plus custom events in [start, endExclusive), sorted by start + /// with overlaps resolved. + static List _mapWindow( Iterable source, DateTime now, TimetableGetSubjectsResponse? subjects, - TimetableGetRoomsResponse? rooms, { + TimetableGetRoomsResponse? rooms, + GetCustomTimetableEventResponse? customEvents, + DateTime start, + DateTime endExclusive, { required bool showClassInsteadOfTeacher, - }) => source.map( - (l) => _mapLesson( - l, - now, - subjects, - rooms, - showClassInsteadOfTeacher: showClassInsteadOfTeacher, - ), - ); + }) { + final mapped = [ + for (final lesson in source) + _mapLesson( + lesson, + now, + subjects, + rooms, + showClassInsteadOfTeacher: showClassInsteadOfTeacher, + ), + ..._expandCustomEvents(customEvents, start, endExclusive), + ]..sort((a, b) => a.start.compareTo(b.start)); + return _resolveCollisions(mapped); + } static WidgetLesson _mapLesson( McTimetableEntry lesson, DateTime now, TimetableGetSubjectsResponse? subjects, TimetableGetRoomsResponse? rooms, { - bool showClassInsteadOfTeacher = false, + required bool showClassInsteadOfTeacher, }) { final start = lesson.startDateTime; final end = lesson.endDateTime; @@ -395,43 +439,29 @@ class WidgetDataMapper { return '#${rgb.toRadixString(16).padLeft(6, '0').toUpperCase()}'; } - static WidgetLessonStatus _mapStatus(LessonStatus status) { - switch (status) { - case LessonStatus.cancelled: - return WidgetLessonStatus.cancelled; - case LessonStatus.event: - return WidgetLessonStatus.event; - case LessonStatus.irregular: - return WidgetLessonStatus.irregular; - case LessonStatus.teacherChanged: - return WidgetLessonStatus.teacherChanged; - case LessonStatus.duty: - return WidgetLessonStatus.duty; - case LessonStatus.past: - return WidgetLessonStatus.past; - case LessonStatus.ongoing: - return WidgetLessonStatus.ongoing; - case LessonStatus.regular: - return WidgetLessonStatus.regular; - } - } - - static bool _onSameDay(McTimetableEntry lesson, DateTime day) { - return lesson.date.year == day.year && - lesson.date.month == day.month && - lesson.date.day == day.day; - } + static WidgetLessonStatus _mapStatus(LessonStatus status) => + switch (status) { + LessonStatus.cancelled => WidgetLessonStatus.cancelled, + LessonStatus.event => WidgetLessonStatus.event, + LessonStatus.irregular => WidgetLessonStatus.irregular, + LessonStatus.teacherChanged => WidgetLessonStatus.teacherChanged, + LessonStatus.duty => WidgetLessonStatus.duty, + LessonStatus.past => WidgetLessonStatus.past, + LessonStatus.ongoing => WidgetLessonStatus.ongoing, + LessonStatus.regular => WidgetLessonStatus.regular, + }; static McHoliday? _findHoliday( DateTime day, TimetableGetHolidaysResponse? holidays, ) { if (holidays == null) return null; - final asDay = DateTime(day.year, day.month, day.day); + final asDay = day.dateOnly; for (final h in holidays.result) { - final start = DateTime(h.startDate.year, h.startDate.month, h.startDate.day); - final end = DateTime(h.endDate.year, h.endDate.month, h.endDate.day); - if (!asDay.isBefore(start) && !asDay.isAfter(end)) return h; + if (!asDay.isBefore(h.startDate.dateOnly) && + !asDay.isAfter(h.endDate.dateOnly)) { + return h; + } } return null; } diff --git a/lib/widget_data/widget_publisher.dart b/lib/widget_data/widget_publisher.dart index 6dd8f39..046cad5 100644 --- a/lib/widget_data/widget_publisher.dart +++ b/lib/widget_data/widget_publisher.dart @@ -80,22 +80,10 @@ class WidgetPublisher { ]); _lastFlags = flags; } - final lessons = state.getAllKnownLessons(); final now = widgetNow(); - final dayData = WidgetDataMapper.buildDayData( + final (day: dayData, week: weekData) = WidgetDataMapper.buildSnapshot( now: now, - lessons: lessons, - subjects: state.subjects, - rooms: state.rooms, - holidays: state.schoolHolidays, - timegrid: state.timegrid, - customEvents: state.customEvents, - connectDoubleLessons: connectDouble, - showClassInsteadOfTeacher: isTeacher, - ); - final weekData = WidgetDataMapper.buildWeekData( - now: now, - lessons: lessons, + lessons: state.getAllKnownLessons(), subjects: state.subjects, rooms: state.rooms, holidays: state.schoolHolidays, @@ -121,10 +109,7 @@ class WidgetPublisher { } _lastSignature = signature; _lastPublishedAt = now; - await WidgetSync.writeDayData(dayData); - await WidgetSync.writeWeekData(weekData); - await WidgetSync.setLoggedIn(true); - await WidgetSync.triggerUpdate(); + await WidgetSync.publishSnapshot(day: dayData, week: weekData); } on Object catch (e, s) { // Catch Object: non-Exception Errors (RangeError, StateError) from the // bloc layer must not escape into the stream listener. @@ -135,15 +120,5 @@ class WidgetPublisher { static Map _withoutFetchedAt(Map json) => Map.of(json)..remove('fetchedAt'); - static String _themeName(ThemeMode? mode) { - switch (mode) { - case ThemeMode.light: - return 'light'; - case ThemeMode.dark: - return 'dark'; - case ThemeMode.system: - case null: - return 'system'; - } - } + static String _themeName(ThemeMode? mode) => (mode ?? ThemeMode.system).name; } diff --git a/lib/widget_data/widget_sync.dart b/lib/widget_data/widget_sync.dart index e5de012..94e3b74 100644 --- a/lib/widget_data/widget_sync.dart +++ b/lib/widget_data/widget_sync.dart @@ -53,13 +53,21 @@ class WidgetSync { static Future writeWeekData(WidgetTimetableData data) => _writeData(weekDataKey, data); + /// Writes a complete snapshot and marks the widget signed in, then asks the + /// native side to re-render. + static Future publishSnapshot({ + required WidgetTimetableData day, + required WidgetTimetableData week, + }) async { + await writeDayData(day); + await writeWeekData(week); + await setLoggedIn(true); + await triggerUpdate(); + } + static Future _writeData(String key, WidgetTimetableData data) async { - await ensureInitialized(); - await HomeWidget.saveWidgetData(key, jsonEncode(data.toJson())); - await HomeWidget.saveWidgetData( - fetchedAtKey, - data.fetchedAt.toIso8601String(), - ); + await _setString(key, jsonEncode(data.toJson())); + await _setString(fetchedAtKey, data.fetchedAt.toIso8601String()); } static Future setLoggedIn(bool loggedIn) => @@ -88,27 +96,30 @@ class WidgetSync { return await HomeWidget.getWidgetData(key) ?? defaultValue; } - static Future setThemeMode(String mode) async { - await ensureInitialized(); - await HomeWidget.saveWidgetData(themeModeKey, mode); - } + static Future setThemeMode(String mode) => + _setString(themeModeKey, mode); - static Future setMarianumConnectBaseUrl(String url) async { - await ensureInitialized(); - await HomeWidget.saveWidgetData(marianumConnectBaseUrlKey, url); - } + static Future setMarianumConnectBaseUrl(String url) => + _setString(marianumConnectBaseUrlKey, url); - static Future getMarianumConnectBaseUrl() async { - await ensureInitialized(); - return HomeWidget.getWidgetData(marianumConnectBaseUrlKey); - } + static Future getMarianumConnectBaseUrl() => + _getString(marianumConnectBaseUrlKey); static Future getFetchedAt() async { - await ensureInitialized(); - final raw = await HomeWidget.getWidgetData(fetchedAtKey); + final raw = await _getString(fetchedAtKey); return raw == null ? null : DateTime.tryParse(raw); } + static Future _setString(String key, String? value) async { + await ensureInitialized(); + await HomeWidget.saveWidgetData(key, value); + } + + static Future _getString(String key) async { + await ensureInitialized(); + return HomeWidget.getWidgetData(key); + } + static Future clear() async { WidgetPublisher.resetDedupe(); await ensureInitialized();